From cac03a695e318bd022c85bc9c442e74ec42310db Mon Sep 17 00:00:00 2001 From: Max Chernyak Date: Sun, 11 Aug 2024 01:19:37 -0400 Subject: [PATCH] Implement first version --- .github/workflows/main.yml | 30 ++ .gitignore | 8 + .tool-versions | 1 + CHANGELOG.md | 5 + CODE_OF_CONDUCT.md | 132 ++++++++ Gemfile | 9 + Gemfile.lock | 22 ++ LICENSE.txt | 21 ++ README.md | 227 ++++++++++++++ Rakefile | 8 + bin/console | 16 + bin/setup | 6 + lib/loadout.rb | 161 ++++++++++ lib/loadout/version.rb | 5 + loadout.gemspec | 34 +++ test/test_helper.rb | 5 + test/test_loadout.rb | 602 +++++++++++++++++++++++++++++++++++++ 17 files changed, 1292 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .tool-versions create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 Rakefile create mode 100755 bin/console create mode 100755 bin/setup create mode 100644 lib/loadout.rb create mode 100644 lib/loadout/version.rb create mode 100644 loadout.gemspec create mode 100644 test/test_helper.rb create mode 100644 test/test_loadout.rb diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..d2b715e --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,30 @@ +name: Ruby + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: + - '3.3.4' + - '3.2.5' + - '3.1.6' + - '3.0.7' + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run the default task + run: bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9106b2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..609b825 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.0.7 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d4c8700 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## [Unreleased] + +## [0.1.0] - 2024-08-11 + +- Initial release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..67fe8ce --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +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, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +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. + +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. + +## Scope + +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 email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**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. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**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. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**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. + +### 4. Permanent Ban + +**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. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..54014c7 --- /dev/null +++ b/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +# Specify your gem's dependencies in loadout.gemspec +gemspec + +gem 'rake', '~> 13.0' +gem 'minitest', '~> 5.16' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..6b3b36d --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,22 @@ +PATH + remote: . + specs: + loadout (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + minitest (5.24.1) + rake (13.2.1) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + loadout! + minitest (~> 5.16) + rake (~> 13.0) + +BUNDLED WITH + 2.5.17 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..9775326 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Max Chernyak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a4d0d1 --- /dev/null +++ b/README.md @@ -0,0 +1,227 @@ +# Loadout + +Rails vanilla config is good enough, but tends to get messy. This gem provides a few helpers to + +- Reduce repetition +- Raise error when required ENV vars or credentials are unset +- Parse reasonable ENV values into booleans, integers, floats, and arrays + +## Synopsis + +```ruby +Rails.application.configure do + extend Loadout::Helpers + + config.some_secret = cred(:secret) { 'default' } + config.value_from_env_or_cred = env.cred(:key_name) + + prefix(:service) do + config.x.service.optional_string = env.cred(:api_key) { 'default_key' } + config.x.service.required_string = env.cred(:api_secret) + + config.x.service.optional_bool = bool.env(:bool_flag) { false } + config.x.service.number = int.env.cred(:number) { nil } + config.x.service.float = float.env.cred(:number) + config.x.service.array = list.env(:comma_list) + end +end +``` + +## Installation + +Note: this gem requires Ruby 3. + +Install the gem and add to the application's Gemfile by executing: + + $ bundle add loadout + +If bundler is not being used to manage dependencies, install the gem by executing: + + $ gem install loadout + +## Usage + +1. Include helpers into your `config/application.rb` and `config/environments/*.rb`: + + ```ruby + extend Loadout::Helpers + ``` + + This should be done in each file where you'd like to use loadout. + +2. Grab a value from credentials: + + ```ruby + config.key = cred(:key_name) + ``` + +3. Or from ENV: + + ```ruby + config.key = env(:key_name) + ``` + +4. Or whichever one is found first: + + ```ruby + config.key = env.cred(:key_name) + ``` + +5. Or the other way around: + + ```ruby + config.key = cred.env(:key_name) + ``` + +6. If it's a nested credential value, you can supply multiple keys: + + ```ruby + # Look up service.key_name in credentials + config.key = cred(:service, :key_name) + ``` + +7. It will do the right thing if you also add env: + + ```ruby + # Look up service.key_name in credentials, or SERVICE_KEY_NAME in ENV + config.key = cred.env(:service, :key_name) + ``` + +8. Parse ENV value into a boolean: + + ```ruby + # Valid true strings: 1/y/yes/t/true + # Valid false strings: "" or 0/n/no/f/false + # (case insensitive) + # + # Any other string will raise an error. + config.some_flag = bool.cred.env(:key_name) + ``` + + Note: because credentials come from YAML, they don't need to be parsed. Only ENV values are parsed. + +9. Integers and floats are also supported: + + ```ruby + config.some_int = int.cred.env(:int_key_name) + config.some_float = float.cred.env(:float_key_name) + ``` + +10. Lists are supported too: + + ```ruby + # Parses strings like "foo, bar, baz", "foo|bar|baz", "foo bar baz" into ['foo', 'bar', 'baz'] + config.some_list = list.cred.env(:key_name) + ``` + +11. You can set your own list separator (string or regex): + + ```ruby + # Parses 'foo0bar0baz' as ['foo', 'bar', 'baz'] + config.some_list = list('0').env(:key_name) + ``` + +12. Use a block at the end to specify a default value: + + ```ruby + config.some_list = list.cred.env(:key_name) { ['default'] } + ``` + +13. Use prefix to avoid repeating the same nesting: + + ```ruby + prefix(:service) do + config.x.service.api_key = env(:api_key) # Looks up "SERVICE_API_KEY" + config.x.service.api_secret = env(:api_secret) # Looks up "SERVICE_API_SECRET" + end + ``` + + Note that left hand side is unaffected. Only loadout helpers get auto-prefixed. + +14. If you'd like a way to shorten the left hand side too, you can assign the whole group as a hash or OrderedOptions (this is not a loadout feature, just something you can do with Rails): + + ```ruby + prefix(:service) do + config.x.service = ActiveSupport::OrderedOptions[ + api_key: env(:api_key), + api_secret: env(:api_secret) + ] + end + ``` + +15. Since `prefix` returns the block's result, you can rewrite the above as follows: + + ```ruby + config.x.service = prefix(:service) { + ActiveSupport::OrderedOptions[ + api_key: env(:api_key), + api_secret: env(:api_secret) + ] + } + ``` + +16. `prefix` lets you supply a default to the whole block: + + ```ruby + prefix(:service, default: -> { 'SECRET' }) do + config.x.service.api_key = env(:api_key) # falls back to 'SECRET' + config.x.service.api_secret = env(:api_secret) # falls back to 'SECRET' + end + ``` + +## Advanced configuration + +### I don't like all these helpers polluting my config! + +Instead of `extend Loadout::Helpers` you can `extend Loadout` to include one proxy method `loadout`. Now all helpers live in one place. + +```ruby +Rails.application.configure do + extend Loadout + + config.some_key = loadout.cred.env(:some_key) +end +``` + +Feel free to alias it to something shorter if you'd like: + +```ruby +Rails.application.configure do + extend Loadout + alias l loadout + + config.some_key = l.cred.env(:some_key) +end +``` + +### Credentials and ENV + +By default loadout will look into `credentials` and `ENV` in your config's context. If your credentials are called something else, or you want to supply an alternative source of ENV, you can configure it like so: + +```ruby +Rails.application.configure do + extend Loadout::Helpers + loadout creds: alt_credentials, env: alt_env + + # Now loadout will use alt_credentials and alt_env to look up values. +end +``` + + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/loadout. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/loadout/blob/main/CODE_OF_CONDUCT.md). + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +## Code of Conduct + +Everyone interacting in the Loadout project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/loadout/blob/main/CODE_OF_CONDUCT.md). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..6983b59 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' +require 'minitest/test_task' + +Minitest::TestTask.create + +task default: :test diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..9dcee67 --- /dev/null +++ b/bin/console @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/setup' +require 'loadout' +require 'irb' + +config = Class.new.new.tap { _1.extend(Loadout::Helpers) } + +config.instance_eval do + loadout(env: $env = { 'VAR' => 'var' }, creds: $cred = { cred: 'cred' }) + + # You're inside the config. Call cred, env, or any other loadout helpers. + # Override $env or $cred to test different configurations. + binding.irb +end diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..cf4ad25 --- /dev/null +++ b/bin/setup @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install diff --git a/lib/loadout.rb b/lib/loadout.rb new file mode 100644 index 0000000..a9bb701 --- /dev/null +++ b/lib/loadout.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require_relative 'loadout/version' +require 'set' + +module Loadout + NONE = BasicObject.new + DEFAULT_LIST_SEP = /\s*[\s[[:punct:]]]+\s*/ + + ConfigError = Class.new(ArgumentError) + MissingConfigError = Class.new(ConfigError) + InvalidConfigError = Class.new(ConfigError) + + def loadout(env: nil, creds: nil) + @loadout ||= Loadout::Config.new(env || ENV, creds || credentials) + end + + module Helpers + def loadout(env: nil, creds: nil) + @loadout ||= Loadout::Config.new(env || ENV, creds || credentials) + end + + def cred(*a, **k, &b) = loadout.cred(*a, **k, &b) + def env(*a, **k, &b) = loadout.env(*a, **k, &b) + def prefix(*a, **k, &b) = loadout.prefix(*a, **k, &b) + + def bool(*a, **k, &b) = loadout.bool(*a, **k, &b) + def int(*a, **k, &b) = loadout.int(*a, **k, &b) + def float(*a, **k, &b) = loadout.float(*a, **k, &b) + def list(*a, **k, &b) = loadout.list(*a, **k, &b) + end + + class Config + protected attr_writer :type + protected attr_reader :lookup_stack + + def initialize(env, creds) + @env = env + @creds = creds + @type = nil + @prefix_stack = [] + @lookup_stack = Set[] + @prefix_default = NONE + end + + def env(*keys, &default) + return dup.tap { _1.lookup_stack << :env } if keys.empty? + @lookup_stack << :env + lookup(keys, &default) + end + + def cred(*keys, &default) + return dup.tap { _1.lookup_stack << :cred } if keys.empty? + @lookup_stack << :cred + lookup(keys, &default) + end + + def prefix(*keys, default: NONE) + @prefix_default = default unless default.equal?(NONE) + @prefix_stack.push(keys) + yield.tap { @prefix_stack.pop } + end + + def bool = dup.tap { _1.type = :bool } + def int = dup.tap { _1.type = :int } + def float = dup.tap { _1.type = :float } + def list(sep = DEFAULT_LIST_SEP) = dup.tap { _1.type = [:list, sep] } + + def initialize_dup(other) + @creds = other.instance_variable_get(:@creds) + @type = other.instance_variable_get(:@type).dup + @prefix_stack = other.instance_variable_get(:@prefix_stack).dup + @lookup_stack = other.instance_variable_get(:@lookup_stack).dup + + unless other.instance_variable_get(:@prefix_default).equal?(NONE) + @prefix_default = other.instance_variable_get(:@prefix_default).dup + end + + super + end + + private + + def lookup(keys) + value = NONE + keys = @prefix_stack.flatten + keys + + @lookup_stack.each do |source| + value = + case source + when :cred; lookup_cred(keys) + when :env; lookup_env(keys) + end + + return value unless value.equal?(NONE) + end + + return yield if block_given? + return @prefix_default.call unless @prefix_default.equal?(NONE) + raise_missing(keys) + ensure + @lookup_stack.clear + end + + def lookup_cred(keys) + return @creds[keys[0]] if keys.one? && @creds.has_key?(keys[0]) + return NONE if keys.one? + hash = @creds.dig(*keys[..-2]) + hash&.has_key?(keys.last) ? hash[keys.last] : NONE + end + + def lookup_env(keys) + env_key = keys.join('_').upcase + @env.has_key?(env_key) ? coerce(env_key, @env[env_key]) : NONE + end + + def coerce(key, value) + case @type + in :bool + value = value.to_s + return false if value == '' + return false if %w[0 n no f false].include?(value.downcase) + return true if %w[1 y yes t true].include?(value.downcase) + raise_invalid :bool, key, value + in :int; enhance_exception(:int, key, value) { Integer(value) } + in :float; enhance_exception(:float, key, value) { Float(value) } + in :list, sep; value.split(sep) + else; value + end + end + + def enhance_exception(type, key, val) + yield + rescue ArgumentError + raise_invalid(type, key, val) + end + + def raise_missing(keys) + src = [] + val = [] + + @lookup_stack.each do |source| + case source + when :cred + src << "credential" + val << keys.join('.') + when :env + src << "environment variable" + val << keys.join('_').upcase + end + end + + msg = src.zip(val).map { |s, v| "#{s} (#{v})" }.join(' or ') + raise MissingConfigError, "required #{msg} is not set" + end + + def raise_invalid(type, key, val) + raise InvalidConfigError, "invalid value for #{type} (`#{val}`) in #{key}" + end + end +end diff --git a/lib/loadout/version.rb b/lib/loadout/version.rb new file mode 100644 index 0000000..5c849f3 --- /dev/null +++ b/lib/loadout/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Loadout + VERSION = '0.1.0' +end diff --git a/loadout.gemspec b/loadout.gemspec new file mode 100644 index 0000000..b1dc311 --- /dev/null +++ b/loadout.gemspec @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative 'lib/loadout/version' + +Gem::Specification.new do |spec| + spec.name = 'loadout' + spec.version = Loadout::VERSION + spec.authors = ['Max Chernyak'] + spec.email = ['hello@max.engineer'] + + spec.summary = 'Rails configuration helpers' + spec.description = 'A few helpers to make vanilla Rails configuration neater.' + spec.homepage = 'https://github.com/maxim/loadout' + spec.license = 'MIT' + spec.required_ruby_version = '>= 3.0.0' + + spec.metadata['allowed_push_host'] = 'https://rubygems.org' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = spec.homepage + spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + gemspec = File.basename(__FILE__) + spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| + ls.readlines("\x0", chomp: true).reject do |f| + (f == gemspec) || f.start_with?(*%w[bin/ test/ .git .github Gemfile]) + end + end + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..541c36a --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'loadout' +require 'minitest/autorun' diff --git a/test/test_loadout.rb b/test/test_loadout.rb new file mode 100644 index 0000000..086cb5e --- /dev/null +++ b/test/test_loadout.rb @@ -0,0 +1,602 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TestLoadout < Minitest::Test + ENV_KEY = 'LOADOUT_TEST_ENV' + ENV_SYM = ENV_KEY.downcase.to_sym + ENV_SYMS = ENV_KEY.split('_').map { _1.downcase.to_sym } + + def setup + config_class = Class.new { attr_accessor :credentials } + + @config1 = config_class.new.tap { _1.extend(Loadout) } + @config1.credentials = {} + + @config2 = config_class.new.tap { _1.extend(Loadout::Helpers) } + @config2.credentials = {} + end + + def teardown = ENV.delete(ENV_KEY) + + ############################################################################## + # MODULE INCLUDE TESTS # + ############################################################################## + def test_config_without_helpers_has_only_loadout + assert_respond_to @config1, :loadout + + refute_respond_to @config1, :cred + refute_respond_to @config1, :env + refute_respond_to @config1, :prefix + refute_respond_to @config1, :bool + refute_respond_to @config1, :int + refute_respond_to @config1, :float + refute_respond_to @config1, :list + end + + def test_config_with_helpers_has_all_helpers + assert_respond_to @config2, :loadout + assert_respond_to @config2, :cred + assert_respond_to @config2, :env + assert_respond_to @config2, :prefix + assert_respond_to @config2, :bool + assert_respond_to @config2, :int + assert_respond_to @config2, :float + assert_respond_to @config2, :list + end + + def test_env_and_creds_are_configurable + alt_env = { 'FOO' => 'bar' } + alt_creds = { baz: 'qux' } + c1 = Class.new.new.tap { _1.extend(Loadout) } + c2 = Class.new.new.tap { _1.extend(Loadout::Helpers) } + + c1.loadout(creds: alt_creds, env: alt_env) + assert_equal 'bar', c1.loadout.env(:foo) + assert_equal 'qux', c1.loadout.cred(:baz) + assert_equal ['bar'], c1.loadout.list.env(:foo) + assert_equal 'qux', c1.loadout.list.env.cred(:baz) + + c2.loadout(creds: alt_creds, env: alt_env) + assert_equal 'bar', c2.env(:foo) + assert_equal 'qux', c2.cred(:baz) + assert_equal ['bar'], c2.list.env(:foo) + assert_equal 'qux', c2.list.list.env.cred(:baz) + end + + def test_loadout_can_be_aliased + @config1.instance_eval { alias l loadout } + set_env 'foo' + assert_equal 'foo', @config1.l.env(ENV_SYM) + end + + ############################################################################## + # CRED, ENV, PREFIX ISOLATION TESTS # + ############################################################################## + def test_cred_looks_up_cred_over_default + @config1.credentials = { foo: 'cred', bar: { baz: 'cred2' } } + @config2.credentials = { foo: 'cred', bar: { baz: 'cred2' } } + + assert_equal 'cred', @config1.loadout.cred(:foo) { 'default' } + assert_equal 'cred', @config2.loadout.cred(:foo) { 'default' } + assert_equal 'cred', @config2.cred(:foo) { 'default' } + + assert_equal 'cred2', @config1.loadout.cred(:bar, :baz) { 'default' } + assert_equal 'cred2', @config2.loadout.cred(:bar, :baz) { 'default' } + assert_equal 'cred2', @config2.cred(:bar, :baz) { 'default' } + end + + def test_cred_looks_up_default_when_cred_is_missing + assert_equal 'default', @config1.loadout.cred(:foo) { 'default' } + assert_equal 'default', @config1.loadout.cred(:bar, :baz) { 'default' } + assert_equal 'default', @config2.cred(:foo) { 'default' } + assert_equal 'default', @config2.cred(:bar, :baz) { 'default' } + end + + def test_cred_raises_when_cred_and_default_are_missing + ex = assert_raises(Loadout::MissingConfigError) { + @config1.loadout.cred(:a, :b) + } + assert_equal 'required credential (a.b) is not set', ex.message + + ex = assert_raises(Loadout::MissingConfigError) { @config2.cred(:a, :b) } + assert_equal 'required credential (a.b) is not set', ex.message + end + + def test_env_looks_up_env_over_default + set_env 'env' + assert_equal 'env', @config1.loadout.env(:loadout_test_env) { 'default' } + assert_equal 'env', @config2.loadout.env(:loadout_test_env) { 'default' } + assert_equal 'env', @config2.env(:loadout_test_env) { 'default' } + end + + def test_env_looks_up_default_when_env_is_missing + assert_equal 'default', @config1.loadout.env(:foo) { 'default' } + assert_equal 'default', @config2.env(:foo) { 'default'} + end + + def test_env_raises_when_env_and_default_are_missing + ex = assert_raises(Loadout::ConfigError) { @config1.loadout.env(*ENV_SYMS) } + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.env(*ENV_SYMS) } + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + end + + def test_prefix_looks_up_nested_cred + @config1.credentials = { foo: { bar: { baz: 'cred' } } } + @config2.credentials = { foo: { bar: { baz: 'cred' } } } + + value1 = @config1.instance_eval { + loadout.prefix(:foo) { loadout.prefix(:bar) { loadout.cred(:baz) } } + } + + value2 = @config2.instance_eval { + prefix(:foo) { prefix(:bar) { cred(:baz) } } + } + + assert_equal 'cred', value1 + assert_equal 'cred', value2 + end + + def test_prefix_applies_default_to_nested_cred + value1 = @config1.instance_eval { + loadout.prefix(:foo, default: -> { 'default' }) { + loadout.prefix(:bar) { + loadout.cred(:baz) + } + } + } + + value2 = @config2.instance_eval { + prefix(:foo) { + prefix(:bar, default: -> { 'default' }) { + cred(:baz) + } + } + } + + assert_equal 'default', value1 + assert_equal 'default', value2 + end + + def test_prefix_shows_key_in_cred_error + ex = assert_raises(Loadout::ConfigError) { + @config1.instance_eval { loadout.prefix(:foo) { loadout.cred(:bar) } } + } + + assert_equal 'required credential (foo.bar) is not set', ex.message + + ex = assert_raises(Loadout::ConfigError) { + @config2.instance_eval { prefix(:foo) { cred(:bar) } } + } + + assert_equal 'required credential (foo.bar) is not set', ex.message + end + + def test_prefix_looks_up_nested_env + set_env 'env' + + value1 = @config1.instance_eval { + loadout.prefix(:loadout) { loadout.prefix(:test) { loadout.env(:env) } } + } + + value2 = @config2.instance_eval { + prefix(:loadout) { prefix(:test) { env(:env) } } + } + + assert_equal 'env', value1 + assert_equal 'env', value2 + end + + def test_prefix_applies_default_to_nested_env + value1 = @config1.instance_eval { + loadout.prefix(:foo, default: -> { 'default' }) { + loadout.prefix(:bar) { + loadout.env(:baz) + } + } + } + + value2 = @config2.instance_eval { + prefix(:foo) { + prefix(:bar, default: -> { 'default' }) { + env(:baz) + } + } + } + + assert_equal 'default', value1 + assert_equal 'default', value2 + end + + def test_prefix_shows_key_in_env_error + ex = assert_raises(Loadout::ConfigError) { + @config1.instance_eval { loadout.prefix(:loadout) { loadout.env(:test) } } + } + + assert_equal 'required environment variable (LOADOUT_TEST) is not set', + ex.message + + ex = assert_raises(Loadout::ConfigError) { + @config2.instance_eval { prefix(:loadout) { env(:test) } } + } + + assert_equal 'required environment variable (LOADOUT_TEST) is not set', + ex.message + end + + ############################################################################## + # BOOL TESTS # + ############################################################################## + def test_bool_accepts_falsy_strings + ['', 'false', 'F', 'nO', 'n', '0'].each do |value| + set_env value + assert_equal false, @config1.loadout.bool.env(ENV_SYM) + assert_equal false, @config2.loadout.bool.env(ENV_SYM) + assert_equal false, @config2.bool.env(ENV_SYM) + end + end + + def test_bool_accepts_truthy_strings + ['1', 'true', 't', 'yes', 'Y'].each do |value| + set_env value + assert_equal true, @config1.loadout.bool.env(ENV_SYM) + assert_equal true, @config2.loadout.bool.env(ENV_SYM) + assert_equal true, @config2.bool.env(ENV_SYM) + end + end + + def test_bool_raises_on_missing_key + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.bool.env(ENV_SYM) + } + + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.bool.env(ENV_SYM) } + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + end + + def test_bool_raises_on_invalid_string + ['bad', 'tr', '#'].each do |value| + set_env value + + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.bool.env(ENV_SYM) + } + + assert_equal "invalid value for bool (`#{value}`) in #{ENV_KEY}", + ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.bool.env(ENV_SYM) } + assert_equal "invalid value for bool (`#{value}`) in #{ENV_KEY}", + ex.message + end + end + + def test_bool_does_not_raise_with_default + assert_equal true, @config1.loadout.bool.env(ENV_SYM) { true } + assert_equal true, @config2.bool.env(ENV_SYM) { true } + end + + def test_bool_raises_with_invalid_string_and_default + set_env 'bad' + + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.bool.env(ENV_SYM) { true } + } + assert_equal "invalid value for bool (`bad`) in #{ENV_KEY}", ex.message + + ex = assert_raises(Loadout::ConfigError) { + @config2.bool.env(ENV_SYM) { true } + } + assert_equal "invalid value for bool (`bad`) in #{ENV_KEY}", ex.message + end + + ############################################################################## + # INT TESTS # + ############################################################################## + def test_int_coerces_string_to_integer + set_env '42' + assert_equal 42, @config1.loadout.int.env(ENV_SYM) + assert_equal 42, @config2.loadout.int.env(ENV_SYM) + assert_equal 42, @config2.int.env(ENV_SYM) + end + + def test_int_raises_on_missing_key + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.int.env(ENV_SYM) + } + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.int.env(ENV_SYM) } + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + end + + def test_int_raises_on_invalid_string + set_env 'bad' + + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.int.env(ENV_SYM) + } + assert_equal "invalid value for int (`bad`) in #{ENV_KEY}", ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.int.env(ENV_SYM) } + assert_equal "invalid value for int (`bad`) in #{ENV_KEY}", ex.message + end + + def test_int_does_not_raise_with_default + assert_equal 42, @config1.loadout.int.env(ENV_SYM) { 42 } + assert_equal 42, @config2.int.env(ENV_SYM) { 42 } + end + + def test_int_raises_with_invalid_string_and_default + set_env 'bad' + + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.int.env(ENV_SYM) { 42 } + } + assert_equal "invalid value for int (`bad`) in #{ENV_KEY}", ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.int.env(ENV_SYM) { 42 } } + assert_equal "invalid value for int (`bad`) in #{ENV_KEY}", ex.message + end + + ############################################################################## + # FLOAT TESTS # + ############################################################################## + def test_float_coerces_string_to_float + set_env '3.14' + assert_equal 3.14, @config1.loadout.float.env(ENV_SYM) + assert_equal 3.14, @config2.loadout.float.env(ENV_SYM) + assert_equal 3.14, @config2.float.env(ENV_SYM) + end + + def test_float_raises_on_missing_key + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.float.env(ENV_SYM) + } + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.float.env(ENV_SYM) } + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + end + + def test_float_raises_on_invalid_string + set_env 'bad' + + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.float.env(ENV_SYM) + } + assert_equal "invalid value for float (`bad`) in #{ENV_KEY}", ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.float.env(ENV_SYM) } + assert_equal "invalid value for float (`bad`) in #{ENV_KEY}", ex.message + end + + def test_float_does_not_raise_with_default + assert_equal 3.14, @config1.loadout.float.env(ENV_SYM) { 3.14 } + assert_equal 3.14, @config2.float.env(ENV_SYM) { 3.14 } + end + + def test_float_raises_with_invalid_string_and_default + set_env 'bad' + + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.float.env(ENV_SYM) { 3.14 } + } + assert_equal "invalid value for float (`bad`) in #{ENV_KEY}", ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.float.env(ENV_SYM) { 3.14 } } + assert_equal "invalid value for float (`bad`) in #{ENV_KEY}", ex.message + end + + ############################################################################## + # LIST TESTS # + ############################################################################## + def test_list_splits_string_by_various_separators + set_env 'a,b, c' + assert_equal %w[a b c], @config1.loadout.list.env(ENV_SYM) + assert_equal %w[a b c], @config2.loadout.list.env(ENV_SYM) + assert_equal %w[a b c], @config2.list.env(ENV_SYM) + + set_env 'a b c' + assert_equal %w[a b c], @config1.loadout.list.env(ENV_SYM) + assert_equal %w[a b c], @config2.loadout.list.env(ENV_SYM) + assert_equal %w[a b c], @config2.list.env(ENV_SYM) + + set_env 'a | b:c' + assert_equal %w[a b c], @config1.loadout.list.env(ENV_SYM) + assert_equal %w[a b c], @config2.loadout.list.env(ENV_SYM) + assert_equal %w[a b c], @config2.list.env(ENV_SYM) + + set_env '1 - 2; 3' + assert_equal %w[1 2 3], @config1.loadout.list.env(ENV_SYM) + assert_equal %w[1 2 3], @config2.loadout.list.env(ENV_SYM) + assert_equal %w[1 2 3], @config2.list.env(ENV_SYM) + end + + def test_list_separator_is_configurable + set_env 'a |b| c' + assert_equal ['a ', 'b', ' c'], @config1.loadout.list('|').env(ENV_SYM) + assert_equal ['a ', 'b', ' c'], @config2.loadout.list('|').env(ENV_SYM) + assert_equal ['a ', 'b', ' c'], @config2.list('|').env(ENV_SYM) + end + + def test_list_raises_on_missing_key + ex = assert_raises(Loadout::ConfigError) { + @config1.loadout.list.env(ENV_SYM) + } + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + + ex = assert_raises(Loadout::ConfigError) { @config2.list.env(ENV_SYM) } + assert_equal "required environment variable (#{ENV_KEY}) is not set", + ex.message + end + + def test_list_does_not_raise_with_default + assert_equal %w[a b c], @config1.loadout.list.env(ENV_SYM) { %w[a b c] } + assert_equal %w[a b c], @config2.list.env(ENV_SYM) { %w[a b c] } + end + + ############################################################################## + # COMBO TESTS # + ############################################################################## + def test_cred_env_looks_up_cred + @config1.credentials = { ENV_SYM => 'cred' } + @config2.credentials = { ENV_SYM => 'cred' } + set_env 'env' + + assert_equal 'cred', @config1.loadout.cred.env(ENV_SYM) + assert_equal 'cred', @config2.loadout.cred.env(ENV_SYM) + assert_equal 'cred', @config2.cred.env(ENV_SYM) + end + + def test_cred_env_looks_up_nested_cred + @config1.credentials = { loadout: { test: { env: 'cred' } } } + @config2.credentials = { loadout: { test: { env: 'cred' } } } + + assert_equal 'cred', @config1.loadout.cred.env(*ENV_SYMS) + assert_equal 'cred', @config2.loadout.cred.env(*ENV_SYMS) + assert_equal 'cred', @config2.cred.env(*ENV_SYMS) + end + + def test_cred_env_looks_up_env_after_cred + set_env 'env' + + assert_equal 'env', @config1.loadout.cred.env(ENV_SYM) { 'default' } + assert_equal 'env', @config2.loadout.cred.env(ENV_SYM) { 'default' } + assert_equal 'env', @config2.cred.env(ENV_SYM) { 'default' } + end + + def test_cred_env_looks_up_default_after_cred_and_env + assert_equal 'default', @config1.loadout.cred.env(ENV_SYM) { 'default' } + assert_equal 'default', @config2.loadout.cred.env(ENV_SYM) { 'default' } + assert_equal 'default', @config2.cred.env(ENV_SYM) { 'default' } + end + + def test_bool_cred_env_looks_up_bool_cred + @config1.credentials = { ENV_SYM => true } + @config2.credentials = { ENV_SYM => true } + set_env 'false' + + assert_equal true, @config1.loadout.bool.cred.env(ENV_SYM) + assert_equal true, @config2.loadout.bool.cred.env(ENV_SYM) + assert_equal true, @config2.bool.cred.env(ENV_SYM) + end + + def test_bool_cred_env_looks_up_bool_env + set_env 'true' + + assert_equal true, @config1.loadout.bool.cred.env(ENV_SYM) { 'default' } + assert_equal true, @config2.loadout.bool.cred.env(ENV_SYM) { 'default' } + assert_equal true, @config2.bool.cred.env(ENV_SYM) { 'default' } + end + + def test_bool_cred_env_looks_up_bool_default + assert_equal false, @config1.loadout.bool.cred.env(ENV_SYM) { false } + assert_equal false, @config2.loadout.bool.cred.env(ENV_SYM) { false } + assert_equal false, @config2.bool.cred.env(ENV_SYM) { false } + end + + def test_env_cred_looks_up_env + @config1.credentials = { ENV_SYM => 'cred' } + @config2.credentials = { ENV_SYM => 'cred' } + set_env 'env' + + assert_equal 'env', @config1.loadout.env.cred(ENV_SYM) + assert_equal 'env', @config2.loadout.env.cred(ENV_SYM) + assert_equal 'env', @config2.env.cred(ENV_SYM) + end + + def test_env_cred_looks_up_cred_after_env + set_env 'env' + + assert_equal 'env', @config1.loadout.env.cred(ENV_SYM) { 'default' } + assert_equal 'env', @config2.loadout.env.cred(ENV_SYM) { 'default' } + assert_equal 'env', @config2.env.cred(ENV_SYM) { 'default' } + end + + def test_env_cred_looks_up_default_after_env_and_cred + assert_equal 'default', @config1.loadout.env.cred(ENV_SYM) { 'default' } + assert_equal 'default', @config2.loadout.env.cred(ENV_SYM) { 'default' } + assert_equal 'default', @config2.env.cred(ENV_SYM) { 'default' } + end + + def test_bool_env_cred_looks_up_bool_env + set_env 'true' + + assert_equal true, @config1.loadout.bool.env.cred(ENV_SYM) + assert_equal true, @config2.loadout.bool.env.cred(ENV_SYM) + assert_equal true, @config2.bool.env.cred(ENV_SYM) + end + + def test_bool_env_cred_looks_up_bool_cred + @config1.credentials = { ENV_SYM => true } + @config2.credentials = { ENV_SYM => true } + + assert_equal true, @config1.loadout.bool.env.cred(ENV_SYM) { 'default' } + assert_equal true, @config2.loadout.bool.env.cred(ENV_SYM) { 'default' } + assert_equal true, @config2.bool.env.cred(ENV_SYM) { 'default' } + end + + def test_bool_env_cred_looks_up_bool_default + assert_equal true, @config1.loadout.bool.env.cred(ENV_SYM) { true } + assert_equal true, @config2.loadout.bool.env.cred(ENV_SYM) { true } + assert_equal true, @config2.bool.env.cred(ENV_SYM) { true } + end + + def test_prefix_cred_env_looks_up_nested_cred + @config1.credentials = { loadout: { test: { env: 'cred' } } } + @config2.credentials = { loadout: { test: { env: 'cred' } } } + set_env 'env' + + value1 = @config1.instance_eval { + loadout.prefix(:loadout) { + loadout.prefix(:test) { + loadout.cred.env(:env) + } + } + } + + value2 = @config2.instance_eval { + prefix(:loadout) { prefix(:test) { cred.env(:env) } } + } + + assert_equal 'cred', value1 + assert_equal 'cred', value2 + end + + def test_prefix_cred_env_looks_up_nested_env + set_env 'env' + + value1 = @config1.instance_eval { + loadout.prefix(:loadout) { + loadout.prefix(:test) { + loadout.cred.env(:env) + } + } + } + + value2 = @config2.instance_eval { + prefix(:loadout) { prefix(:test) { cred.env(:env) } } + } + + assert_equal 'env', value1 + assert_equal 'env', value2 + end + + private + + def set_env(value) = ENV[ENV_KEY] = value +end