diff --git a/Gemfile.lock b/Gemfile.lock index 6b2d2bb..6c37660 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - nvar (0.2.0) + nvar (0.3.0) activesupport (>= 5.0.0, < 8.0) GEM @@ -148,6 +148,7 @@ GEM parser (3.3.0.5) ast (~> 2.4.1) racc + prism (0.24.0) psych (5.1.2) stringio racc (1.7.3) @@ -225,6 +226,12 @@ GEM rubocop-performance (1.20.2) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.30.0, < 2.0) + ruby-lsp (0.14.3) + language_server-protocol (~> 3.17.0) + prism (>= 0.22.0, < 0.25) + sorbet-runtime (>= 0.5.10782) + ruby-lsp-rspec (0.1.10) + ruby-lsp (~> 0.14.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) simplecov (0.22.0) @@ -233,6 +240,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) + sorbet-runtime (0.5.11276) standard (1.34.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -277,6 +285,7 @@ DEPENDENCIES rails rake (~> 12.0) rspec (~> 3.0) + ruby-lsp-rspec simplecov standardrb tempfile diff --git a/README.md b/README.md index e5b2104..604b895 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,17 @@ If your app relies on lots of environment secrets, onboarding's tough. New team You can use `Nvar` in Ruby apps, with out-of-the-box support provided for Rails. ## Installation -Add the gem to your Gemfile and install it with `bundle add nvar`. If you're not on Rails, you'll need to make sure that `Nvar` is required with `require 'nvar'`, and then manually call `Nvar::EnvironmentVariable.load_all` as early as is appropriate. +Add the gem to your Gemfile and install it with `bundle add nvar`. If you're not on Rails, you'll need to make sure that `Nvar` is required with `require 'nvar'`, and then manually call `Nvar.load_all` as early as is appropriate. + +It's recommended to do this by adding the following to `config/application.rb`: + +```rb +require "dotenv/load" # if using in tandem with dotenv +require "nvar" + +Nvar.load_all +``` + ## Configuration `Nvar` is configured by way of `config/environment_variables.yml`. If you're on Rails, this file will be created for you automatically. Each key corresponds to the name of a required environment variable, and houses its configuration, all of which is optional. @@ -36,7 +46,7 @@ This is just a glimpse of `Nvar`'s greater aim - centralizing configuration for ### Passthrough -The final config option, `passthrough`, deserves some extra detail. By default, `Nvar` sets your environment constants to their actual values in development and production environments, and to their names in test environments. +The final config option, `passthrough`, deserves some extra detail. By default, `Nvar` sets your environment constants to their actual values in development and production environments, and to their names in test environments, in order to prevent your tests from depending on their values. In production/development, or in test with passthrough active: @@ -52,17 +62,20 @@ irb(main):001:0> REQUIRED_ENV_VAR => "REQUIRED_ENV_VAR" ``` -Your tests shouldn't be reliant on your environment, so generally, you want to have `passthrough` set to `true` as little as possible. What it *is* useful for, however, is recording VCR cassettes. Set `passthrough: true` on necessary environment variables before recording VCR cassettes, then remove it and run your tests again to make sure they're not reliant on your environment. +Your tests shouldn't be reliant on your environment, so setting `passthrough` to `true` in `config/environment_variables.yml` isn't recommended. Passthrough is, however, useful for recording VCR cassettes. Set NVAR_PASSTHROUGH to a comma-separated list of environment variable names while running your tests, then unset it and run your tests again to make sure they're not reliant on your environment. For example: +``` +NVAR_PASSTHROUGH=GITHUB_APP_PRIVATE_KEY,GITHUB_APP_INSTALLATION_ID bundle exec rspec +``` ## Usage Now that you've been through and configured the environment variables that are necessary for your app, `Nvar` will write your environment variables to top-level constants, cast to any types you've specified, and raise an informative error if any are absent. ### .env files -`Nvar` works well with gems like `dotenv-rails` that source their config from a `.env` file. If an environment variable is unset when the app initializes and isn't present in `.env`, it will be added to that file. If a default value is specified in your `Nvar` config, that will be passed to `.env` too. +`Nvar` works well with gems like `dotenv` that source their config from a `.env` file. If an environment variable is unset when the app initializes and isn't present in `.env`, it will be added to that file. If a default value is specified in your `Nvar` config, that will be passed to `.env` too. -When using gems such as `dotenv-rails`, make sure you load those first so that the environment is ready for `Nvar` to check. +When using gems such as `dotenv`, make sure you load those first so that the environment is ready for `Nvar` to check. ### `rake nvar:verify_environment_file` diff --git a/lib/nvar.rb b/lib/nvar.rb index 35a28a4..f18d758 100644 --- a/lib/nvar.rb +++ b/lib/nvar.rb @@ -4,11 +4,14 @@ require "nvar/environment_variable" require "nvar/engine" if defined?(Rails) require "active_support/core_ext/module/attribute_accessors" +require "active_support/core_ext/hash/reverse_merge" +require "active_support/string_inquirer" # Centralized configuration for required environment variables in your Ruby app. module Nvar mattr_accessor :config_file_path, default: File.expand_path("config/environment_variables.yml") mattr_accessor :env_file_path, default: File.expand_path(".env") + mattr_accessor :env # Comments in .env files must have a leading '#' symbol. This cannot be # followed by a space. @@ -38,12 +41,21 @@ def message end class << self + def env + if defined?(Rails) + Rails.env + else + ActiveSupport::StringInquirer.new(@@env&.to_s || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development") + end + end + def configure_for_rails(app) self.config_file_path = app.root.join("config/environment_variables.yml") self.env_file_path = app.root.join(".env") [config_file_path, env_file_path].each do |path| File.open(path, "w") {} unless path.exist? # rubocop:disable Lint/EmptyBlock end + self.env = Rails.env end def load_all @@ -62,7 +74,6 @@ def filter_from_vcr_cassettes(config) def all variables.map do |variable_name, config| - # TODO: Passthrough from environment behaviour might need to go here? EnvironmentVariable.new(**(config || {}).merge(name: variable_name)) end.partition(&:set?) end diff --git a/lib/nvar/environment_variable.rb b/lib/nvar/environment_variable.rb index 1915851..661617b 100644 --- a/lib/nvar/environment_variable.rb +++ b/lib/nvar/environment_variable.rb @@ -16,7 +16,7 @@ def initialize(name:, type: "String", filter_from_requests: nil, **args) @type = type @required = args[:required].nil? ? true : args[:required] @filter_from_requests = filter_from_requests.yield_self { |f| [true, false].include?(f) ? f : f&.to_sym } - @value = fetch_value(**args.slice(:passthrough, :default_value)) + @value = fetch_value(**args.slice(:passthrough, :default_value).with_defaults(passthrough: ENV.fetch("NVAR_PASSTHROUGH", "").split(",").include?(name.to_s))) @defined = true rescue KeyError @value = args[:default_value] @@ -26,7 +26,7 @@ def initialize(name:, type: "String", filter_from_requests: nil, **args) def to_const raise Nvar::EnvironmentVariableNotPresentError, self unless defined - Object.const_set(name, typecast_value) + Object.const_set(name, typecast_value) unless Object.const_defined?(name) end def set? @@ -76,7 +76,7 @@ def typecast_value end def fetch_value(passthrough: false, default_value: nil) - return default_value || name if ENV["RAILS_ENV"] == "test" && !passthrough + return default_value || name.to_s if Nvar.env.test? && !passthrough required ? ENV.fetch(name.to_s) : ENV[name.to_s] end diff --git a/lib/nvar/version.rb b/lib/nvar/version.rb index de634cb..c315b59 100644 --- a/lib/nvar/version.rb +++ b/lib/nvar/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Nvar - VERSION = "0.2.0" + VERSION = "0.3.0" end diff --git a/nvar.gemspec b/nvar.gemspec index 8de1b66..4e43dab 100644 --- a/nvar.gemspec +++ b/nvar.gemspec @@ -33,6 +33,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "byebug" spec.add_development_dependency "climate_control" spec.add_development_dependency "rails" + spec.add_development_dependency "ruby-lsp-rspec" spec.add_development_dependency "simplecov" spec.add_development_dependency "standardrb" spec.add_development_dependency "tempfile" diff --git a/spec/nvar/environment_variable_spec.rb b/spec/nvar/environment_variable_spec.rb index b7c2114..bb673d3 100644 --- a/spec/nvar/environment_variable_spec.rb +++ b/spec/nvar/environment_variable_spec.rb @@ -27,6 +27,17 @@ let(:environment_variable) { described_class.new(**args) } + around do |example| + Nvar.env.then do |old_env| + Nvar.env = ActiveSupport::StringInquirer.new(nvar_env.to_s) + example.run + ensure + Nvar.env = old_env + end + end + + let(:nvar_env) { :development } + describe "#initialize" do subject(:initializer) { environment_variable } @@ -112,25 +123,205 @@ context "when passthrough is false and a default value is provided" do let(:args) { base_args.merge(passthrough: false, default_value: "default_value") } - it { is_expected.to eq("default_value") } + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq("default_value") } + end end context "when passthrough is true and a default value is provided" do let(:args) { base_args.merge(passthrough: true, default_value: "default_value") } - it { is_expected.to eq("passthrough_value") } + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq("passthrough_value") } + end end context "when passthrough is false and a default value is not provided" do let(:args) { base_args.merge(passthrough: false, default_value: nil) } - it { is_expected.to eq(environment_variable.name) } + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq(environment_variable.name) } + end end context "when passthrough is true and a default value is not provided" do let(:args) { base_args.merge(passthrough: true, default_value: nil) } - it { is_expected.to eq("passthrough_value") } + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq("passthrough_value") } + end + end + + context "when passthrough is unset in the environment and a default value is not provided" do + let(:args) { base_args.except(:passthrough).merge(default_value: nil) } + + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq(environment_variable.name) } + end + end + + context "when passthrough is unset in the environment and a default value is provided" do + let(:args) { base_args.except(:passthrough).merge(default_value: "default_value") } + + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq("default_value") } + end + end + + context "when passthrough is enabled through the environment and a default value is provided" do + around { |example| ClimateControl.modify(NVAR_PASSTHROUGH: "SOME_OTHER_VAR,#{base_args[:name]}") { example.run } } + + let(:args) { base_args.except(:passthrough).merge(default_value: "default_value") } + + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq("passthrough_value") } + end + end + + context "when passthrough is enabled through the environment and a default value is not provided" do + around { |example| ClimateControl.modify(NVAR_PASSTHROUGH: "SOME_OTHER_VAR,#{base_args[:name]}") { example.run } } + + let(:args) { base_args.except(:passthrough).merge(default_value: nil) } + + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq("passthrough_value") } + end + end + + context "when passthrough is enabled through the environment, but is overridden by the config" do + around { |example| ClimateControl.modify(NVAR_PASSTHROUGH: "SOME_OTHER_VAR,#{base_args[:name]}") { example.run } } + + let(:args) { base_args.merge(passthrough: false, default_value: nil) } + + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq(environment_variable.name) } + end + end + + context "when passthrough is disabled through the environment and a default value is provided" do + around { |example| ClimateControl.modify(NVAR_PASSTHROUGH: "SOME_OTHER_VAR") { example.run } } + + let(:args) { base_args.except(:passthrough).merge(default_value: "default_value") } + + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq("default_value") } + end + end + + context "when passthrough is disabled through the environment and a default value is not provided" do + around { |example| ClimateControl.modify(NVAR_PASSTHROUGH: "false") { example.run } } + + let(:args) { base_args.except(:passthrough).merge(default_value: nil) } + + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq(environment_variable.name) } + end + end + + context "when passthrough is disabled through the environment, but is overridden by the config" do + around { |example| ClimateControl.modify(NVAR_PASSTHROUGH: "SOME_OTHER_VAR") { example.run } } + + let(:args) { base_args.merge(passthrough: true) } + + context "and the environment is development" do + let(:nvar_env) { :development } + + it { is_expected.to eq("passthrough_value") } + end + + context "and the environment is test" do + let(:nvar_env) { :test } + + it { is_expected.to eq("passthrough_value") } + end end end end diff --git a/spec/nvar_spec.rb b/spec/nvar_spec.rb index 58ab045..deff28b 100644 --- a/spec/nvar_spec.rb +++ b/spec/nvar_spec.rb @@ -77,6 +77,8 @@ context "when the env file exists and an optional environment variable is unset" do let!(:env) { base_env.except(:OPTIONAL_ENV_VAR) } + + it { is_expected.to be_empty } end context "when the env file exists and an environment variable with a default value is unset" do