Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ db/structure.sql

/app/assets/builds/*
!/app/assets/builds/.keep

# Ignore key files for decrypting credentials and more.
/config/*.key

1 change: 1 addition & 0 deletions app/models/current.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :beacon
attribute :feature_overrides
delegate :user, to: :session, allow_nil: true
end
62 changes: 62 additions & 0 deletions app/models/feature_flags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
module FeatureFlags
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to move it to services

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's an argument for both. It feels like it has a better fit in /models because it's tightly coupled with Current. Also, because it doesn't really orchestrate business logic like the other services do.

extend self

def enabled?(flag_name, user: nil)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use overrides, env vars and setup from creadentials

override = Current.feature_overrides&.dig(flag_name.to_sym)
return override unless override.nil?

env_value = ENV["FEATURE_#{flag_name.to_s.upcase}"]
return cast_boolean(env_value) unless env_value.nil?

config = feature_config(flag_name)
return false if config.nil?
return cast_boolean(config) unless config.respond_to?(:dig)

return true if cast_boolean(config[:enabled])

user_allowed?(config, user)
end

def disabled?(flag_name, user: nil)
!enabled?(flag_name, user: user)
end

def enable!(flag_name)
(Current.feature_overrides ||= {})[flag_name.to_sym] = true
end

def disable!(flag_name)
(Current.feature_overrides ||= {})[flag_name.to_sym] = false
end

def with(flag_name, value)
old = Current.feature_overrides&.dig(flag_name.to_sym)
value ? enable!(flag_name) : disable!(flag_name)
yield
ensure
if old.nil?
Current.feature_overrides&.delete(flag_name.to_sym)
else
Current.feature_overrides[flag_name.to_sym] = old
end
end

private

def feature_config(flag_name)
Rails.application.credentials.dig(:features, flag_name.to_sym)
end

def user_allowed?(config, user)
return false if user.nil?

allowed_emails = config[:allowed_emails]
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to identify users in a different way later

return false if allowed_emails.blank?

allowed_emails.include?(user.email)
end

def cast_boolean(value)
ActiveModel::Type::Boolean.new.cast(value) || false
end
end
1 change: 1 addition & 0 deletions config/credentials.yml.enc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
yjspzEXQf5fPuCTqkmnahCuQrqf3YDn0Tk1qyro/FP2Tp/Wz9P0CGt2+kkRBpl5ozR+vf6zLODRaFThjJgOazYqS6bWApMzo1jUn6kTPLTY9AOUF4VOxUajbZpwXUDJL/nG0sdg3y+dv3g6/nyCgu6BNndO1BJBezgFRoocUFfKyAQXkZTs7c0KCIsLjoJlK8pY+l6d5jowQqmaB6gJVTCBXDAV5Jr3n/wSM2gzQuVEup22em/sbRCXXR2GPdHgfXtthEMYh5ePOwFGqFimDymO4ZqrnTNHzEiO1zJdeAjdGjg0mNarFbefMtaLpKbnxeqaObm2tOPQVhEIGXOhIsB+a5oLJjnXuDSe5+UoYIBXHygRxx8p6ktqWovyIqDLWB8ojDW/YwrqXy4qoj8SACvg4tKQcKRhFJGJwXxzN2bu3G5yZVZQ75uvSwf0FEOATBEMqOnlvjzPZsvEKQnPCxqCPMf54R8YnNVEbdv+DWtWJlmSJrA6p+/jQ7ZgXmgyIWl/merILgSaCXcbMPqCoVwl1wLkdFjab5d/e6/xz2i/giPHv31o1bvl8RGFhZOz2DeDnqiYMv3GZ/f3oDeiMv/8LamSkQYPr3DqtFmms1UA=--oKBd19vdWhjbzt0B--ppPQiT/cJjaOxgDtNZyU1A==
149 changes: 149 additions & 0 deletions spec/models/feature_flags_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
require "rails_helper"

RSpec.describe FeatureFlags do
after { Current.feature_overrides = nil }

describe ".enabled?" do
context "with request-scoped override" do
it "returns true when flag is overridden to true" do
described_class.enable!(:beacons)

expect(described_class.enabled?(:beacons)).to be true
end

it "returns false when flag is overridden to false" do
described_class.disable!(:beacons)

expect(described_class.enabled?(:beacons)).to be false
end

it "takes precedence over credentials" do
allow(Rails.application.credentials).to receive(:dig)
.with(:features, :beacons).and_return(true)

described_class.disable!(:beacons)

expect(described_class.enabled?(:beacons)).to be false
end
end

context "with ENV override" do
it "returns true when ENV is set to true" do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("FEATURE_BEACONS").and_return("true")

expect(described_class.enabled?(:beacons)).to be true
end

it "returns false when ENV is set to false" do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("FEATURE_BEACONS").and_return("false")

expect(described_class.enabled?(:beacons)).to be false
end
end

context "with boolean credential" do
it "returns true when credential is true" do
allow(Rails.application.credentials).to receive(:dig)
.with(:features, :my_flag).and_return(true)

expect(described_class.enabled?(:my_flag)).to be true
end

it "returns false when credential is false" do
allow(Rails.application.credentials).to receive(:dig)
.with(:features, :my_flag).and_return(false)

expect(described_class.enabled?(:my_flag)).to be false
end
end

context "with hash credential and allowed_emails" do
before do
allow(Rails.application.credentials).to receive(:dig)
.with(:features, :beacons)
.and_return({ enabled: false, allowed_emails: [ "admin@skillrx.org" ] })
end

it "returns true for a user whose email is allowed" do
user = build(:user, email: "admin@skillrx.org")

expect(described_class.enabled?(:beacons, user: user)).to be true
end

it "returns false for a user whose email is not allowed" do
user = build(:user, email: "other@example.com")

expect(described_class.enabled?(:beacons, user: user)).to be false
end

it "returns false when no user is provided" do
expect(described_class.enabled?(:beacons)).to be false
end
end

context "with globally enabled hash credential" do
before do
allow(Rails.application.credentials).to receive(:dig)
.with(:features, :beacons)
.and_return({ enabled: true, allowed_emails: [ "admin@skillrx.org" ] })
end

it "returns true regardless of user" do
expect(described_class.enabled?(:beacons)).to be true
end
end

context "with unknown flag" do
it "returns false" do
allow(Rails.application.credentials).to receive(:dig)
.with(:features, :unknown).and_return(nil)

expect(described_class.enabled?(:unknown)).to be false
end
end
end

describe ".disabled?" do
it "returns the inverse of enabled?" do
allow(Rails.application.credentials).to receive(:dig)
.with(:features, :beacons).and_return(true)

expect(described_class.disabled?(:beacons)).to be false
end
end

describe ".with" do
it "enables the flag within the block" do
value_inside = nil

described_class.with(:beacons, true) do
value_inside = described_class.enabled?(:beacons)
end

expect(value_inside).to be true
expect(described_class.enabled?(:beacons)).to be false
end

it "disables the flag within the block" do
described_class.enable!(:beacons)

value_inside = nil
described_class.with(:beacons, false) do
value_inside = described_class.enabled?(:beacons)
end

expect(value_inside).to be false
expect(described_class.enabled?(:beacons)).to be true
end

it "restores the previous override after the block" do
described_class.enable!(:beacons)

described_class.with(:beacons, false) { }

expect(Current.feature_overrides[:beacons]).to be true
end
end
end