├── .circleci └── config.yml ├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── env_bang-rails.gemspec ├── env_bang.gemspec ├── lib ├── env_bang-rails.rb ├── env_bang.rb └── env_bang │ ├── classes.rb │ ├── formatter.rb │ └── version.rb └── test ├── env_bang_test.rb └── test_helper.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Use the latest 2.1 version of CircleCI pipeline process engine. 2 | # See: https://circleci.com/docs/2.0/configuration-reference 3 | version: 2.1 4 | 5 | orbs: 6 | ruby: circleci/ruby@2.0.0 7 | coveralls: coveralls/coveralls@2.1.0 8 | 9 | # Define a job to be invoked later in a workflow. 10 | # See: https://circleci.com/docs/2.0/configuration-reference/#jobs 11 | jobs: 12 | test: 13 | parameters: 14 | ruby-version: 15 | type: string 16 | docker: 17 | - image: cimg/ruby:<< parameters.ruby-version >> 18 | resource_class: small 19 | environment: 20 | COVERAGE: Summary 21 | steps: 22 | - checkout 23 | - run: bundle install 24 | - run: bundle exec rake 25 | - coveralls/upload: 26 | coverage_file: coverage/lcov.info 27 | compare_ref: main 28 | parallel: true 29 | flag_name: test-<< parameters.ruby-version >> 30 | done: 31 | docker: 32 | - image: cimg/ruby:3.2 33 | steps: 34 | - coveralls/upload: 35 | carryforward: 'test-3.2' 36 | parallel_finished: true 37 | 38 | 39 | # Invoke jobs via workflows 40 | # See: https://circleci.com/docs/2.0/configuration-reference/#workflows 41 | workflows: 42 | buidle_and_test: 43 | jobs: 44 | - test: 45 | matrix: 46 | parameters: 47 | # https://github.com/CircleCI-Public/cimg-ruby 48 | # only supports the last three ruby versions 49 | ruby-version: ["2.6", "2.7", "3.0", "3.2"] 50 | - done: 51 | requires: 52 | - test -------------------------------------------------------------------------------- /.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 | vendor/bundle 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec name: 'env_bang' 4 | 5 | group :test do 6 | gem 'simplecov' 7 | gem 'simplecov-html' 8 | gem 'simplecov-lcov' 9 | end 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Jonathan Camenisch 2 | 3 | MIT License 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ENV! 2 | 3 | Verify correctness of environment configuration at startup time. 4 | 5 | [![Gem Version](https://img.shields.io/gem/v/env_bang?logo=rubygems)](https://rubygems.org/gems/env_bang) 6 | [![Build Status](https://img.shields.io/circleci/build/github/jcamenisch/ENV_BANG/main)](https://dl.circleci.com/status-badge/redirect/gh/jcamenisch/ENV_BANG/tree/main) 7 | [![Maintainability](https://img.shields.io/codeclimate/maintainability/jcamenisch/ENV_BANG?logo=codeclimate)](https://codeclimate.com/github/jcamenisch/ENV_BANG) 8 | [![Coverage Status](https://img.shields.io/coverallsCoverage/github/jcamenisch/ENV_BANG?logo=coveralls)](https://coveralls.io/r/jcamenisch/ENV_BANG) 9 | 10 | ENV! provides a thin wrapper around ENV to accomplish a few things: 11 | 12 | - Provide a central place to specify all your app’s environment variables. 13 | - Fail loudly and helpfully if any environment variables are missing or invalid. 14 | - Prevent an application from starting up with invalid environment variables. 15 | 16 | ## Installation 17 | 18 | Add this line to your application’s Gemfile: 19 | 20 | ```ruby 21 | gem 'env_bang' 22 | ``` 23 | 24 | Or for Rails apps, use `env_bang-rails` instead for more convenience: 25 | 26 | ```ruby 27 | gem 'env_bang-rails' 28 | ``` 29 | 30 | And then execute: 31 | 32 | ```sh 33 | $ bundle 34 | ``` 35 | 36 | ## Usage 37 | 38 | ### Basic Configuration 39 | 40 | First, configure your environment variables somewhere in your app’s 41 | startup process. If you use the env_bang-rails gem, place this in `config/env.rb` 42 | to load before application configuration. 43 | 44 | Example configuration: 45 | 46 | ```ruby 47 | ENV!.config do 48 | use :APP_HOST 49 | use :RAILS_SECRET_TOKEN 50 | use :STRIPE_SECRET_KEY 51 | use :STRIPE_PUBLISHABLE_KEY 52 | # ... etc. 53 | end 54 | ``` 55 | 56 | Once a variable is specified with the `use` method, access it with 57 | 58 | ```ruby 59 | ENV!['MY_VAR'] 60 | ``` 61 | 62 | This will function just like accessing `ENV` directly, except that it will require the variable 63 | to have been specified, and be present in the current environment. If either of these conditions 64 | is not met, a KeyError will be raised with an explanation of what needs to be configured. 65 | 66 | ### Adding a default value 67 | 68 | For some variables, you’ll want to include a default value in your code, and allow each 69 | environment to ommit the variable for default behaviors. You can accomplish this with the 70 | `:default` option: 71 | 72 | ```ruby 73 | ENV!.config do 74 | # ... 75 | use :MAIL_DELIVERY_METHOD, default: 'smtp' 76 | # ... 77 | end 78 | ``` 79 | 80 | ### Adding a description 81 | 82 | When a new team member installs or deploys your project, they may run into a missing 83 | environment variable error. Save them time by including documentation along with the error 84 | that is raised. To accomplish this, provide a description (of any length) to the `use` method: 85 | 86 | ```ruby 87 | ENV!.config do 88 | use 'RAILS_SECRET_KEY_BASE', 89 | 'Generate a fresh one with `SecureRandom.urlsafe_base64(64)`; see http://guides.rubyonrails.org/security.html#session-storage' 90 | end 91 | ``` 92 | 93 | Now if someone installs or deploys the app without setting the RAILS_SECRET_KEY_BASE variable, 94 | they will see these instructions immediately upon running the app. 95 | 96 | ### Automatic type conversion 97 | 98 | ENV! can convert your environment variables for you, keeping that tedium out of your application 99 | code. To specify a type, use the `:class` option: 100 | 101 | ```ruby 102 | ENV!.config do 103 | use :COPYRIGHT_YEAR, class: Integer 104 | use :MEMCACHED_SERVERS, class: Array 105 | use :MAIL_DELIVERY_METHOD, class: Symbol, default: :smtp 106 | use :DEFAULT_FRACTION, class: Float 107 | use :ENABLE_SOUNDTRACK, class: :boolean 108 | use :PUPPETMASTERS, class: Hash 109 | end 110 | ``` 111 | 112 | Note that arrays will be derived by splitting the value on commas (','). To get arrays 113 | of a specific type of value, use the `:of` option: 114 | 115 | ```ruby 116 | ENV!.config do 117 | use :YEARS_OF_INTEREST, class: Array, of: Integer 118 | end 119 | ``` 120 | 121 | Hashes are split on commas (',') and key:value pairs are delimited by colon (':'). To get hashes of a specific type of value, use the `:of` option, and to use a different type for keys (default is `Symbol`), use the `:keys` option: 122 | 123 | ```ruby 124 | ENV!.config do 125 | use :BIRTHDAYS, class: Hash, of: Integer, keys: String 126 | end 127 | ``` 128 | 129 | #### Default type conversion behavior 130 | 131 | If you don’t specify a `:class` option for a variable, ENV! defaults to a special 132 | type conversion called `:StringUnlessFalsey`. This conversion returns a string, unless 133 | the value is a "falsey" string ('false', 'no', 'off', '0', 'disable', or 'disabled'). 134 | To turn off this magic for one variable, pass in `class: String`. To disable it globally, 135 | set 136 | 137 | ```ruby 138 | ENV!.config do 139 | default_class String 140 | end 141 | ``` 142 | 143 | #### Custom type conversion 144 | 145 | Suppose your app needs a special type conversion that doesn’t come with ENV_BANG. You can 146 | implement the conversion yourself with the `add_class` method in the `ENV!.config` block. 147 | For example, to convert one of your environment variables to type `Set`, you could write 148 | the following configuration: 149 | 150 | ```sh 151 | # In your environment: 152 | export NUMBER_SET=1,3,5,7,9 153 | ``` 154 | 155 | ```ruby 156 | # In your env.rb configuration file: 157 | require 'set' 158 | 159 | ENV!.config do 160 | add_class Set do |value, options| 161 | Set.new self.Array(value, options || {}) 162 | end 163 | 164 | use :NUMBER_SET, class: Set, of: Integer 165 | end 166 | ``` 167 | 168 | ```ruby 169 | # Somewhere in your application: 170 | ENV!['NUMBER_SET'] 171 | #=> # 172 | ``` 173 | 174 | ## Implementation Notes 175 | 176 | 1. ENV! is simply a method that returns ENV_BANG. In certain contexts 177 | (like defining a class), the exclamation mark notation is not allowed, 178 | so we use an alias to get this shorthand. 179 | 180 | 2. Any method that can be run within an `ENV!.config` block can also be run 181 | as a method directly on `ENV!`. For instance, instead of 182 | 183 | ```ruby 184 | ENV!.config do 185 | add_class Set do 186 | ... 187 | end 188 | 189 | use :NUMBER_SET, class: Set 190 | end 191 | ``` 192 | 193 | It would also work to run 194 | 195 | ```ruby 196 | ENV!.add_class Set do 197 | ... 198 | end 199 | 200 | ENV!.use :NUMBER_SET, class: Set 201 | ``` 202 | 203 | While the `config` block is designed to provide a cleaner configuration 204 | file, calling the methods directly can occasionally be handy, such as when 205 | trying things out in an IRB/Pry session. 206 | 207 | ## Contributing 208 | 209 | 1. Fork it 210 | 2. Create your feature branch (`git checkout -b my-new-feature`) 211 | 3. Commit your changes (`git commit -am 'Add some feature'`) 212 | 4. Push to the branch (`git push origin my-new-feature`) 213 | 5. Create new Pull Request 214 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_helper" 3 | require 'rake/testtask' 4 | 5 | namespace 'dotenv' do 6 | Bundler::GemHelper.install_tasks :name => 'env_bang' 7 | end 8 | 9 | namespace 'dotenv-rails' do 10 | Bundler::GemHelper.install_tasks :name => 'env_bang-rails' 11 | end 12 | 13 | desc 'Run all tests' 14 | Rake::TestTask.new do |t| 15 | t.test_files = FileList['test/*_test.rb'] 16 | t.verbose = true 17 | end 18 | 19 | task :default => :test 20 | -------------------------------------------------------------------------------- /env_bang-rails.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'env_bang/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "env_bang-rails" 8 | spec.version = ENV_BANG::VERSION 9 | spec.authors = ["Jonathan Camenisch"] 10 | spec.email = ["jonathan@camenisch.net"] 11 | spec.summary = %q{Use ENV! in Rails} 12 | spec.description = %q{ENV! provides a thin wrapper around ENV to encourage central, self-documenting configuration and helpful error message.} 13 | spec.homepage = "https://github.com/jcamenisch/ENV_BANG" 14 | spec.license = "MIT" 15 | 16 | spec.files = %w[lib/env_bang-rails.rb] 17 | spec.require_paths = ["lib"] 18 | 19 | spec.add_dependency 'env_bang', ENV_BANG::VERSION 20 | end 21 | -------------------------------------------------------------------------------- /env_bang.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'env_bang/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "env_bang" 8 | spec.version = ENV_BANG::VERSION 9 | spec.authors = ["Jonathan Camenisch"] 10 | spec.email = ["jonathan@camenisch.net"] 11 | spec.summary = %q{Do a bang-up job managing your environment variables} 12 | spec.description = %q{ENV! provides a thin wrapper around ENV to encourage central, self-documenting configuration and helpful error message.} 13 | spec.homepage = "https://github.com/jcamenisch/ENV_BANG" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "rake" 22 | spec.add_development_dependency "minitest", "~> 5.1" 23 | end 24 | -------------------------------------------------------------------------------- /lib/env_bang-rails.rb: -------------------------------------------------------------------------------- 1 | require 'env_bang' 2 | 3 | class ENV_BANG 4 | class Railtie < Rails::Railtie 5 | def env_rb_file 6 | File.join(Rails.root.to_s, 'config/env.rb') 7 | end 8 | 9 | def warn(msg) 10 | if Rails.logger 11 | Rails.logger.warn(msg) 12 | else 13 | puts "WARNING: #{msg}" 14 | end 15 | end 16 | 17 | config.before_configuration do 18 | if File.exist?(env_rb_file) 19 | load env_rb_file 20 | else 21 | warn("ENV! could not find your environment variable configuration. Please create #{env_rb_file} to set up environment variables at Rails boot.") 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/env_bang.rb: -------------------------------------------------------------------------------- 1 | require "env_bang/version" 2 | require "env_bang/classes" 3 | require "env_bang/formatter" 4 | require "forwardable" 5 | 6 | class ENV_BANG 7 | class << self 8 | extend Forwardable 9 | include Enumerable 10 | 11 | def config(&block) 12 | instance_eval(&block) 13 | end 14 | 15 | def use(var, *args) 16 | var = var.to_s 17 | description = args.first.is_a?(String) ? args.shift : nil 18 | options = args.last.is_a?(Hash) ? args.pop : {} 19 | 20 | unless ENV.has_key?(var) 21 | ENV[var] = options.fetch(:default) { raise_formatted_error(var, description) }.to_s 22 | end 23 | 24 | vars[var] = options 25 | 26 | # Make sure reading/coercing the value works. If it's going to raise an error, raise it now. 27 | self[var] 28 | end 29 | 30 | def raise_formatted_error(var, description) 31 | e = KeyError.new(Formatter.formatted_error(var, description)) 32 | e.set_backtrace(caller[3..-1]) 33 | raise e 34 | end 35 | 36 | def vars 37 | @vars ||= {} 38 | end 39 | 40 | def keys 41 | vars.keys 42 | end 43 | 44 | def values 45 | keys.map { |k| self[k] } 46 | end 47 | 48 | def [](var) 49 | var = var.to_s 50 | raise KeyError.new("ENV_BANG is not configured to use var #{var}") unless vars.has_key?(var) 51 | 52 | Classes.cast ENV[var], vars[var] 53 | end 54 | 55 | def add_class(klass, &block) 56 | Classes.send :define_singleton_method, klass.to_s, &block 57 | end 58 | 59 | def default_class(*args) 60 | if args.any? 61 | Classes.default_class = args.first 62 | else 63 | Classes.default_class 64 | end 65 | end 66 | 67 | def to_h 68 | keys.map { |k| [k, self[k]] }.to_h 69 | end 70 | 71 | alias to_hash to_h 72 | 73 | #################################### 74 | # Implement Hash-like read methods # 75 | #################################### 76 | 77 | def_delegators :to_h, 78 | :each, :assoc, :each_pair, :each_value, :empty?, :fetch, 79 | :invert, :key, :rassoc, :values_at 80 | 81 | def_delegators :vars, :each_key, :has_key?, :key?, :length, :size 82 | 83 | def slice(*requested_keys) 84 | (requested_keys & keys).map { |k| [k, self[k]] }.to_h 85 | end 86 | 87 | def except(*exceptions) 88 | slice(*(keys - exceptions)) 89 | end 90 | 91 | def value?(value) 92 | values.include?(value) 93 | end 94 | 95 | alias has_value? value? 96 | end 97 | end 98 | 99 | def ENV! 100 | ENV_BANG 101 | end 102 | -------------------------------------------------------------------------------- /lib/env_bang/classes.rb: -------------------------------------------------------------------------------- 1 | class ENV_BANG 2 | module Classes 3 | class << self 4 | attr_writer :default_class 5 | 6 | def default_class 7 | @default_class ||= :StringUnlessFalsey 8 | end 9 | 10 | def cast(value, options = {}) 11 | public_send(:"#{options.fetch(:class, default_class)}", value, options) 12 | end 13 | 14 | def boolean(value, options) 15 | !(value =~ /^(|0|disabled?|false|no|off)$/i) 16 | end 17 | 18 | def Array(value, options) 19 | item_options = options.merge(class: options.fetch(:of, default_class)) 20 | value.split(',').map { |v| cast(v.strip, item_options) } 21 | end 22 | 23 | def Hash(value, options) 24 | key_options = options.merge(class: options.fetch(:keys, Symbol)) 25 | value_options = options.merge(class: options.fetch(:of, default_class)) 26 | sep = options.fetch(:sep, ',') 27 | val_sep = options.fetch(:val_sep, ':') 28 | 29 | {}.tap do |h| 30 | value.split(sep).each do |pair| 31 | key, value = pair.split(val_sep, 2) 32 | h[cast(key.to_s.strip, key_options)] = cast(value.to_s.strip, value_options) 33 | end 34 | end 35 | end 36 | 37 | def Symbol(value, options) 38 | value.to_sym 39 | end 40 | 41 | def StringUnlessFalsey(value, options) 42 | boolean(value, options) && value 43 | end 44 | 45 | def Integer(value, options) 46 | Kernel.Integer(value) 47 | end 48 | 49 | def Float(value, options) 50 | Kernel.Float(value) 51 | end 52 | 53 | def String(value, options) 54 | Kernel.String(value) 55 | end 56 | 57 | def Rational(value, options) 58 | Kernel.Rational(value) 59 | end 60 | 61 | def Complex(value, options) 62 | Kernel.Complex(value) 63 | end 64 | 65 | def Pathname(value, options) 66 | Kernel.Pathname(value) 67 | end 68 | 69 | def URI(value, options) 70 | Kernel.URI(value) 71 | end 72 | 73 | def Date(value, options) 74 | Date.parse(value) 75 | end 76 | 77 | def DateTime(value, options) 78 | DateTime.parse(value) 79 | end 80 | 81 | def Time(value, options) 82 | Time.parse(value) 83 | end 84 | 85 | def Range(value, options = {}) 86 | beginning, ending = value.split('...') 87 | if beginning && ending 88 | options[:exclusive] = true unless options.has_key?(:exclusive) 89 | else 90 | beginning, ending = value.split('..') 91 | end 92 | unless beginning && ending 93 | raise ArgumentError.new("Range '#{value}' cannot be parsed as a range. Must be in the form .. or ...") 94 | end 95 | 96 | options[:of] ||= Integer 97 | beginning = cast(beginning, class: options[:of]) 98 | ending = cast(ending, class: options[:of]) 99 | 100 | if options[:exclusive] 101 | beginning...ending 102 | else 103 | beginning..ending 104 | end 105 | end 106 | 107 | def Regexp(value, options) 108 | Regexp.new(value) 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/env_bang/formatter.rb: -------------------------------------------------------------------------------- 1 | class ENV_BANG 2 | module Formatter 3 | class << self 4 | def formatted_error(var, description) 5 | indent(4, " 6 | 7 | Missing required environment variable: #{var}#{description and "\n" << 8 | unindent(description) } 9 | ") 10 | end 11 | 12 | def unindent(string) 13 | width = string.scan(/^ */).map(&:length).min 14 | string.gsub(/^ {#{width}}/, '') 15 | end 16 | 17 | def indent(width, string) 18 | string.gsub("\n", "\n#{' ' * width}") 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/env_bang/version.rb: -------------------------------------------------------------------------------- 1 | class ENV_BANG 2 | VERSION = "2.0.0.alpha.1" 3 | end 4 | -------------------------------------------------------------------------------- /test/env_bang_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | describe ENV_BANG do 4 | before do 5 | ENV_BANG.instance_eval { @vars = nil } 6 | ENV_BANG::Classes.default_class = nil 7 | end 8 | 9 | it "Raises exception if unconfigured ENV var requested" do 10 | ENV['UNCONFIGURED'] = 'unconfigured' 11 | _{ ENV!['UNCONFIGURED'] }.must_raise KeyError 12 | end 13 | 14 | it "Raises exception if configured ENV var is not present" do 15 | ENV.delete('NOT_PRESENT') 16 | 17 | _{ 18 | ENV!.config do 19 | use 'NOT_PRESENT' 20 | end 21 | }.must_raise KeyError 22 | end 23 | 24 | it "Raises exception immediately if value is invalid for the required type" do 25 | _{ 26 | ENV['NOT_A_DATE'] = '2017-02-30' 27 | ENV!.use 'NOT_A_DATE', class: Date 28 | }.must_raise ArgumentError 29 | 30 | _{ 31 | ENV!.use 'NOT_A_DATE_DEFAULT', class: Date, default: '2017-02-31' 32 | }.must_raise ArgumentError 33 | end 34 | 35 | it "Uses provided default value if ENV var not already present" do 36 | ENV.delete('WASNT_PRESENT') 37 | 38 | ENV!.config do 39 | use 'WASNT_PRESENT', default: 'a default value' 40 | end 41 | _(ENV!['WASNT_PRESENT']).must_equal 'a default value' 42 | end 43 | 44 | it "Returns actual value from ENV if present" do 45 | ENV['PRESENT'] = 'present in environment' 46 | 47 | ENV!.config do 48 | use 'PRESENT', default: "You won't need this." 49 | end 50 | _(ENV!['PRESENT']).must_equal 'present in environment' 51 | end 52 | 53 | describe "Type casting" do 54 | let(:truthy_values) { %w[true on yes yo yup anything] } 55 | let(:falsey_values) { %w[false no off disable disabled 0] << '' } 56 | let(:integers) { %w[0 1 10 -42 -55] } 57 | let(:floats) { %w[0.1 1.3 10 -42.3 -55] } 58 | 59 | it "Casts Integers" do 60 | integer = integers.sample 61 | ENV['INTEGER'] = integer 62 | ENV!.use 'INTEGER', class: Integer 63 | 64 | _(ENV!['INTEGER']).must_equal integer.to_i 65 | end 66 | 67 | it "Casts Symbols" do 68 | ENV['SYMBOL'] = 'symbol' 69 | ENV!.use 'SYMBOL', class: Symbol 70 | 71 | _(ENV!['SYMBOL']).must_equal :symbol 72 | end 73 | 74 | it "Casts Floats" do 75 | float = floats.sample 76 | ENV['FLOAT'] = float 77 | ENV!.use 'FLOAT', class: Float 78 | 79 | _(ENV!['FLOAT']).must_equal float.to_f 80 | _(ENV!['FLOAT'].class).must_equal Float 81 | end 82 | 83 | it "Casts Arrays" do 84 | ENV['ARRAY'] = 'one,two , three, four' 85 | ENV!.use 'ARRAY', class: Array 86 | 87 | _(ENV!['ARRAY']).must_equal %w[one two three four] 88 | end 89 | 90 | it "Casts Arrays of Integers" do 91 | ENV['INTEGERS'] = integers.join(',') 92 | ENV!.use 'INTEGERS', class: Array, of: Integer 93 | 94 | _(ENV!['INTEGERS']).must_equal integers.map(&:to_i) 95 | end 96 | 97 | it "Casts Arrays of Floats" do 98 | ENV['FLOATS'] = floats.join(',') 99 | ENV!.use 'FLOATS', class: Array, of: Float 100 | 101 | _(ENV!['FLOATS']).must_equal floats.map(&:to_f) 102 | end 103 | 104 | it "regression: Casting Array always returns Array" do 105 | ENV['ARRAY'] = 'one,two , three, four' 106 | ENV!.use 'ARRAY', class: Array 107 | 108 | 2.times do 109 | _(ENV!['ARRAY']).must_equal %w[one two three four] 110 | end 111 | end 112 | 113 | it "Casts Hashes" do 114 | ENV['HASH'] = 'one: two, three: http://four.com' 115 | ENV!.use 'HASH', class: Hash 116 | 117 | _(ENV!['HASH']).must_equal({one: 'two', three: 'http://four.com'}) 118 | end 119 | 120 | it 'Casts Hashes of Integers' do 121 | ENV['INT_HASH'] = 'one: 111, two: 222' 122 | ENV!.use 'INT_HASH', class: Hash, of: Integer 123 | 124 | _(ENV!['INT_HASH']).must_equal({one: 111, two: 222}) 125 | end 126 | 127 | it 'Casts Hashes with String keys' do 128 | ENV['STRKEY_HASH'] = 'one: two, three: four' 129 | ENV!.use 'STRKEY_HASH', class: Hash, keys: String 130 | 131 | _(ENV!['STRKEY_HASH']).must_equal({'one' => 'two', 'three' => 'four'}) 132 | end 133 | 134 | it 'Casts Hashes with alternate separators' do 135 | ENV['ALT_HASH'] = 'one:two = three; four,five=six' 136 | ENV!.use 'ALT_HASH', class: Hash, sep: ';', val_sep: '=' 137 | 138 | _(ENV!['ALT_HASH']).must_equal({:'one:two' => 'three', :'four,five' => 'six'}) 139 | end 140 | 141 | it "Casts true" do 142 | ENV['TRUE'] = truthy_values.sample 143 | ENV!.use 'TRUE', class: :boolean 144 | 145 | _(ENV!['TRUE']).must_equal true 146 | end 147 | 148 | it "Casts false" do 149 | ENV['FALSE'] = falsey_values.sample 150 | ENV!.use 'FALSE', class: :boolean 151 | 152 | _(ENV!['FALSE']).must_equal false 153 | end 154 | 155 | it "converts falsey or empty string to false by default" do 156 | ENV['FALSE'] = falsey_values.sample 157 | ENV!.use 'FALSE' 158 | 159 | _(ENV!['FALSE']).must_equal false 160 | end 161 | 162 | it "leaves falsey string as string if specified" do 163 | ENV['FALSE'] = falsey_values.sample 164 | ENV!.use 'FALSE', class: String 165 | 166 | _(ENV!['FALSE'].class).must_equal String 167 | end 168 | 169 | it "casts Dates" do 170 | ENV['A_DATE'] = '2005-05-05' 171 | ENV!.use 'A_DATE', class: Date 172 | 173 | _(ENV!['A_DATE'].class).must_equal Date 174 | _(ENV!['A_DATE']).must_equal Date.new(2005, 5, 5) 175 | end 176 | 177 | it "casts DateTimes" do 178 | ENV['A_DATETIME'] = '2005-05-05 5:05pm' 179 | ENV!.use 'A_DATETIME', class: DateTime 180 | 181 | _(ENV!['A_DATETIME'].class).must_equal DateTime 182 | _(ENV!['A_DATETIME']).must_equal DateTime.new(2005, 5, 5, 17, 5) 183 | end 184 | 185 | it "casts Times" do 186 | ENV['A_TIME'] = '2005-05-05 5:05pm' 187 | ENV!.use 'A_TIME', class: Time 188 | 189 | _(ENV!['A_TIME'].class).must_equal Time 190 | _(ENV!['A_TIME']).must_equal Time.new(2005, 5, 5, 17, 5) 191 | end 192 | 193 | it "casts Regexps" do 194 | # Escaping backslashes is not without its pitfalls. Developer beware. 195 | ENV['A_REGEX'] = '^(this|is|a|[^tes.*\|]t.\.\*/\\\)$' 196 | ENV!.use 'A_REGEX', class: Regexp 197 | 198 | _(ENV!['A_REGEX'].class).must_equal Regexp 199 | _(ENV!['A_REGEX']).must_equal(/^(this|is|a|[^tes.*\|]t.\.\*\/\\)$/) 200 | end 201 | 202 | it "casts inclusive Ranges of Integers by default" do 203 | ENV['A_RANGE'] = '1..100' 204 | ENV!.use 'A_RANGE', class: Range 205 | 206 | _(ENV!['A_RANGE'].class).must_equal Range 207 | _(ENV!['A_RANGE']).must_equal 1..100 208 | end 209 | 210 | it "casts exclusive Ranges as directed" do 211 | ENV['EXCLUSIVE_RANGE'] = '1..100' 212 | ENV!.use 'EXCLUSIVE_RANGE', class: Range, exclusive: true 213 | 214 | _(ENV!['EXCLUSIVE_RANGE']).must_equal 1...100 215 | 216 | ENV['ANOTHER_EXCLUSIVE_RANGE'] = '1...100' 217 | ENV!.use 'ANOTHER_EXCLUSIVE_RANGE', class: Range, exclusive: true 218 | 219 | _(ENV!['ANOTHER_EXCLUSIVE_RANGE']).must_equal 1...100 220 | end 221 | 222 | it "casts Ranges of floats" do 223 | ENV['FLOAT_RANGE'] = '1.5..100.7' 224 | ENV!.use 'FLOAT_RANGE', class: Range, of: Float 225 | 226 | _(ENV!['FLOAT_RANGE']).must_equal 1.5..100.7 227 | end 228 | 229 | it "casts Ranges of strings" do 230 | ENV['STRING_RANGE'] = 'az..za' 231 | ENV!.use 'STRING_RANGE', class: Range, of: String 232 | 233 | _(ENV!['STRING_RANGE']).must_equal 'az'..'za' 234 | end 235 | 236 | it "raises on invalid Range" do 237 | ENV['BAD_RANGE'] = '1..' 238 | _{ 239 | ENV!.use 'BAD_RANGE', class: Range 240 | }.must_raise ArgumentError 241 | end 242 | 243 | it "allows default class to be overridden" do 244 | _(ENV!.default_class).must_equal :StringUnlessFalsey 245 | ENV!.config { default_class String } 246 | ENV['FALSE'] = falsey_values.sample 247 | ENV!.use 'FALSE', class: String 248 | 249 | _(ENV!['FALSE'].class).must_equal String 250 | end 251 | 252 | it "allows addition of custom types" do 253 | require 'set' 254 | 255 | ENV['NUMBER_SET'] = '1,3,5,7,9' 256 | ENV!.config do 257 | add_class Set do |value, options| 258 | Set.new self.Array(value, options || {}) 259 | end 260 | 261 | use :NUMBER_SET, class: Set, of: Integer 262 | end 263 | 264 | _(ENV!['NUMBER_SET']).must_equal Set.new [1, 3, 5, 7, 9] 265 | end 266 | 267 | describe "Kernel casting delegators" do 268 | it "casts Integers" do 269 | ENV['A_INTEGER'] = '-123' 270 | ENV!.use 'A_INTEGER', class: Integer 271 | 272 | _(ENV!['A_INTEGER']).must_equal(-123) 273 | end 274 | 275 | it "casts Floats" do 276 | ENV['A_FLOAT'] = '123.456' 277 | ENV!.use 'A_FLOAT', class: Float 278 | 279 | _(ENV!['A_FLOAT']).must_equal 123.456 280 | end 281 | 282 | it "casts Strings" do 283 | ENV['A_STRING'] = 'What do I write here?' 284 | ENV!.use 'A_STRING', class: String 285 | _(ENV!['A_STRING']).must_equal 'What do I write here?' 286 | end 287 | 288 | it "casts Rationals" do 289 | ENV['A_RATIONAL'] = '3/32' 290 | ENV!.use 'A_RATIONAL', class: Rational 291 | 292 | _(ENV!['A_RATIONAL'].class).must_equal Rational 293 | _(ENV!['A_RATIONAL']).must_equal 3.to_r/32 294 | _(ENV!['A_RATIONAL'].to_s).must_equal '3/32' 295 | end 296 | 297 | it "casts Complexes" do 298 | ENV['A_COMPLEX'] = '123+4i' 299 | ENV!.use 'A_COMPLEX', class: Complex 300 | 301 | _(ENV!['A_COMPLEX'].class).must_equal Complex 302 | _(ENV!['A_COMPLEX'].to_s).must_equal '123+4i' 303 | end 304 | 305 | it "casts Pathnames" do 306 | ENV['A_PATHNAME'] = '~/.git/config' 307 | ENV!.use 'A_PATHNAME', class: Pathname 308 | 309 | _(ENV!['A_PATHNAME'].class).must_equal Pathname 310 | _(ENV!['A_PATHNAME'].to_s).must_equal '~/.git/config' 311 | end 312 | 313 | it "casts URIs" do 314 | ENV['A_URI'] = 'http://www.example.com/path/to/nowhere' 315 | ENV!.use 'A_URI', class: URI 316 | 317 | _(ENV!['A_URI'].class).must_equal URI::HTTP 318 | _(ENV!['A_URI'].to_s).must_equal 'http://www.example.com/path/to/nowhere' 319 | end 320 | end 321 | end 322 | 323 | describe "Hash-like behavior" do 324 | it "provides configured keys" do 325 | ENV['VAR1'] = 'something' 326 | ENV['VAR2'] = 'something else' 327 | ENV!.use 'VAR1' 328 | ENV!.use 'VAR2' 329 | 330 | _(ENV!.keys).must_equal %w[VAR1 VAR2] 331 | end 332 | 333 | it "provides configured values" do 334 | ENV['VAR1'] = 'something' 335 | ENV['VAR2'] = 'something else' 336 | ENV!.use 'VAR1' 337 | ENV!.use 'VAR2' 338 | 339 | _(ENV!.values).must_equal %w[something something\ else] 340 | end 341 | end 342 | 343 | describe "Formatting" do 344 | it "Includes provided description in error message" do 345 | ENV.delete('NOT_PRESENT') 346 | 347 | e = _{ 348 | ENV!.config do 349 | use 'NOT_PRESENT', 'You need a NOT_PRESENT var in your ENV' 350 | end 351 | }.must_raise(KeyError) 352 | _(e.message).must_include 'You need a NOT_PRESENT var in your ENV' 353 | end 354 | 355 | it "Removes indentation from provided descriptions" do 356 | ENV.delete('NOT_PRESENT') 357 | 358 | e = _{ 359 | ENV!.config do 360 | use 'NOT_PRESENT', <<-DESC 361 | This multiline description 362 | has a lot of indentation 363 | varying from line to line 364 | like so 365 | DESC 366 | end 367 | }.must_raise(KeyError) 368 | _(e.message).must_include <<-UNINDENTED 369 | This multiline description 370 | has a lot of indentation 371 | varying from line to line 372 | like so 373 | UNINDENTED 374 | end 375 | end 376 | 377 | describe "Enumerable methods" do 378 | before do 379 | ENV['ONE'] = '1' 380 | ENV['A'] = 'A' 381 | ENV['INT_HASH'] = 'one: 1, two: 2' 382 | ENV['FLOAT'] = '1.234' 383 | 384 | ENV!.config do 385 | use 'ONE', class: Integer 386 | use 'A', class: String 387 | use 'INT_HASH', class: Hash, of: Integer 388 | use 'FLOAT', class: Float 389 | end 390 | end 391 | 392 | it "converts keys and parsed values to a Hash" do 393 | _(ENV!.to_h).must_equal({ 394 | 'ONE' => 1, 395 | 'A' => 'A', 396 | 'INT_HASH' => { one: 1, two: 2 }, 397 | 'FLOAT' => 1.234, 398 | }) 399 | end 400 | 401 | it "Doesn't allow write access via the hash (It's not a reference to internal values)" do 402 | h = ENV!.to_h 403 | h['A'] = 'changed' 404 | _(ENV!['A']).must_equal 'A' 405 | end 406 | 407 | it "returns an Array representation of the hash too" do 408 | _(ENV!.to_a).must_equal [ 409 | ['ONE', 1], 410 | ['A', 'A'], 411 | ['INT_HASH', { one: 1, two: 2 }], 412 | ['FLOAT', 1.234], 413 | ] 414 | end 415 | 416 | it "implements other Enumerable methods too" do 417 | _(ENV!.each.to_a).must_equal [ 418 | ['ONE', 1], 419 | ['A', 'A'], 420 | ['INT_HASH', { one: 1, two: 2 }], 421 | ['FLOAT', 1.234], 422 | ] 423 | 424 | _(ENV!.to_enum.to_a).must_equal ENV!.to_a 425 | end 426 | end 427 | 428 | describe "Hash-like read methods" do 429 | before do 430 | ENV['ONE'] = '1' 431 | ENV['A'] = 'A' 432 | ENV['INT_HASH'] = 'one: 1, two: 2' 433 | ENV['FLOAT'] = '1.234' 434 | 435 | ENV!.config do 436 | use 'ONE', class: Integer 437 | use 'A', class: String 438 | use 'INT_HASH', class: Hash, of: Integer 439 | use 'FLOAT', class: Float 440 | end 441 | end 442 | 443 | it "implements .assoc and .rassoc correctly" do 444 | _(ENV!.assoc('ONE')).must_equal ['ONE', 1] 445 | _(ENV!.rassoc(1)).must_equal ['ONE', 1] 446 | end 447 | 448 | it "implements .each_key correctly" do 449 | _(ENV!.each_key.to_a).must_equal(%w[ONE A INT_HASH FLOAT]) 450 | keys = [] 451 | ENV!.each_key do |key| 452 | keys << key 453 | end 454 | _(keys).must_equal(%w[ONE A INT_HASH FLOAT]) 455 | end 456 | 457 | it "implements .each_pair correctly" do 458 | _(ENV!.each_pair.to_a).must_equal(ENV!.to_a) 459 | pairs = [] 460 | ENV!.each_pair do |pair| 461 | pairs << pair 462 | end 463 | _(pairs).must_equal(ENV!.to_a) 464 | end 465 | 466 | it "implements .each_value correctly" do 467 | _(ENV!.each_value.to_a).must_equal [1, 'A', { one: 1, two: 2 }, 1.234] 468 | values = [] 469 | ENV!.each_value do |value| 470 | values << value 471 | end 472 | _(values).must_equal [1, 'A', { one: 1, two: 2 }, 1.234] 473 | end 474 | 475 | it "implements .empty? correctly" do 476 | _(ENV!.empty?).must_equal(false) 477 | end 478 | 479 | it "implements .except correctly" do 480 | _(ENV!.except('INT_HASH', 'FLOAT', 'NOTATHING')).must_equal({ 481 | 'ONE' => 1, 482 | 'A' => 'A', 483 | }) 484 | end 485 | 486 | it "implements .fetch correctly" do 487 | _(ENV!.fetch('ONE')).must_equal 1 488 | _{ 489 | ENV!.fetch('TWO') 490 | }.must_raise KeyError 491 | _(ENV!.fetch('TWO', 2)).must_equal 2 492 | _(ENV!.fetch('TWO') { 22 }).must_equal 22 493 | end 494 | 495 | it "implements .invert correctly" do 496 | _(ENV!.invert).must_equal({ 497 | 1 => 'ONE', 498 | 'A' => 'A', 499 | { one: 1, two: 2 } => 'INT_HASH', 500 | 1.234 => 'FLOAT', 501 | }) 502 | end 503 | 504 | it "implements .key correctly" do 505 | _(ENV!.key(1)).must_equal 'ONE' 506 | end 507 | 508 | it "implements .key?/.has_key? correctly" do 509 | _(ENV!.key?('ONE')).must_equal true 510 | _(ENV!.has_key?('ONE')).must_equal true 511 | 512 | _(ENV!.key?('TWO')).must_equal false 513 | _(ENV!.has_key?('TWO')).must_equal false 514 | end 515 | 516 | it "implements .length correctly" do 517 | _(ENV!.length).must_equal 4 518 | _(ENV!.size).must_equal 4 519 | end 520 | 521 | it "implements .slice correctly" do 522 | _(ENV!.slice('INT_HASH', 'FLOAT', 'NOTATHING')).must_equal({ 523 | 'INT_HASH' => { one: 1, two: 2 }, 524 | 'FLOAT' => 1.234, 525 | }) 526 | end 527 | 528 | it "implements .to_hash correctly" do 529 | _(ENV!.to_hash).must_equal ENV!.to_h 530 | end 531 | 532 | it "implements .value?/has_value? correctly" do 533 | _(ENV!.value?(1)).must_equal true 534 | _(ENV!.value?(2)).must_equal false 535 | _(ENV!.has_value?(1)).must_equal true 536 | _(ENV!.has_value?(2)).must_equal false 537 | end 538 | 539 | it "implements .values_at correctly" do 540 | _(ENV!.values_at('INT_HASH', 'FLOAT', 'NOTATHING')).must_equal [ 541 | { one: 1, two: 2 }, 1.234, nil 542 | ] 543 | end 544 | end 545 | end 546 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | 3 | SimpleCov.start do 4 | if ENV['CI'] 5 | require 'simplecov-lcov' 6 | 7 | SimpleCov::Formatter::LcovFormatter.config do |c| 8 | c.report_with_single_file = true 9 | c.single_report_path = 'coverage/lcov.info' 10 | end 11 | 12 | formatter SimpleCov::Formatter::MultiFormatter.new([ 13 | SimpleCov::Formatter::HTMLFormatter, 14 | SimpleCov::Formatter::LcovFormatter, 15 | ]) 16 | end 17 | 18 | add_filter %w[version.rb env_bang-rails.rb] 19 | end 20 | 21 | require 'minitest/autorun' 22 | require 'minitest/spec' 23 | require 'env_bang' 24 | --------------------------------------------------------------------------------