Skip to content

Commit

Permalink
#26, #27 - Implement Strategy Constraints and FlexibleRollout Strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
rarruda committed Jul 28, 2020
1 parent 30f6000 commit d11c329
Show file tree
Hide file tree
Showing 13 changed files with 351 additions and 32 deletions.
6 changes: 3 additions & 3 deletions .rubocop.yml
Expand Up @@ -9,7 +9,7 @@ Naming/PredicateName:

Metrics/ClassLength:
Max: 120
Metrics/LineLength:
Layout/LineLength:
Max: 140
Metrics/MethodLength:
Max: 20
Expand All @@ -22,9 +22,9 @@ Metrics/BlockLength:
Metrics/AbcSize:
Max: 25
Metrics/CyclomaticComplexity:
Max: 8
Max: 9
Metrics/PerceivedComplexity:
Max: 8
Max: 9

Style/Documentation:
Enabled: false
Expand Down
3 changes: 1 addition & 2 deletions .travis.yml
Expand Up @@ -7,8 +7,7 @@ rvm:
- 2.5
before_install:
- gem install bundler -v 2.1.4
- git clone --depth 5 --branch v3.2.0 https://github.com/Unleash/client-specification.git
client-specification
- git clone --depth 5 --branch v3.3.0 https://github.com/Unleash/client-specification.git client-specification

notifications:
slack:
Expand Down
19 changes: 16 additions & 3 deletions lib/unleash/activation_strategy.rb
@@ -1,16 +1,29 @@
module Unleash
class ActivationStrategy
attr_accessor :name, :params
attr_accessor :name, :params, :constraints

def initialize(name, params)
def initialize(name, params, constraints = [])
self.name = name

if params.is_a?(Hash)
self.params = params
elsif params.nil?
self.params = {}
else
Unleash.logger.warn "Invalid params provided for ActivationStrategy #{params}"
Unleash.logger.warn "Invalid params provided for ActivationStrategy (params:#{params})"
self.params = {}
end

if constraints.is_a?(Array) && constraints.each{ |c| c.is_a?(Constraint) }
self.constraints = constraints
else
Unleash.logger.warn "Invalid constraints provided for ActivationStrategy (contraints: #{constraints})"
self.constraints = []
end
end

def matches_context?(context)
self.constraints.any?{ |c| c.matches_context? context }
end
end
end
26 changes: 26 additions & 0 deletions lib/unleash/constraint.rb
@@ -0,0 +1,26 @@
module Unleash
class Constraint
attr_accessor :context_name, :operator, :values

VALID_OPERATORS = ['IN', 'NOT_IN'].freeze

def initialize(context_name, operator, values = [])
raise ArgumentError, "context_name is not a String" unless context_name.is_a?(String)
raise ArgumentError, "operator does not hold a valid value:" + VALID_OPERATORS unless VALID_OPERATORS.include? operator
raise ArgumentError, "values does not hold an Array" unless values.is_a?(Array)

self.context_name = context_name
self.operator = operator
self.values = values
end

def matches_context?(context)
Unleash.logger.debug "Unleash::Constraint matches_context? values: #{self.values} context.get_by_name(#{self.context_name})" \
" #{context.get_by_name(self.context_name)} "

is_included = self.values.include? context.get_by_name(self.context_name)

operator == 'IN' ? is_included : !is_included
end
end
end
11 changes: 10 additions & 1 deletion lib/unleash/context.rb
Expand Up @@ -2,7 +2,7 @@ module Unleash
class Context
ATTRS = [:app_name, :environment, :user_id, :session_id, :remote_address].freeze

attr_accessor *[ATTRS, :properties].flatten
attr_accessor(*[ATTRS, :properties].flatten)

def initialize(params = {})
raise ArgumentError, "Unleash::Context must be initialized with a hash." unless params.is_a?(Hash)
Expand Down Expand Up @@ -32,6 +32,15 @@ def get_by_name(name)
end
end

def get_by_name(name)
normalized_name = underscore(name).to_sym
if ATTRS.include? normalized_name
self.send(normalized_name)
else
self.properties.fetch(normalized_name)
end
end

private

# Method to fetch values from hash for two types of keys: string in camelCase and symbol in snake_case
Expand Down
61 changes: 44 additions & 17 deletions lib/unleash/feature_toggle.rb
@@ -1,4 +1,5 @@
require 'unleash/activation_strategy'
require 'unleash/constraint'
require 'unleash/variant_definition'
require 'unleash/variant'
require 'unleash/strategy/util'
Expand All @@ -13,20 +14,9 @@ def initialize(params = {})

self.name = params.fetch('name', nil)
self.enabled = params.fetch('enabled', false)
self.strategies = params.fetch('strategies', [])
.select{ |s| s.has_key?('name') && Unleash::STRATEGIES.has_key?(s['name'].to_sym) }
.map{ |s| ActivationStrategy.new(s['name'], s['parameters'] || {}) } || []

self.variant_definitions = (params.fetch('variants', []) || [])
.select{ |v| v.is_a?(Hash) && v.has_key?('name') }
.map do |v|
VariantDefinition.new(
v.fetch('name', ''),
v.fetch('weight', 0),
v.fetch('payload', nil),
v.fetch('overrides', [])
)
end || []
self.strategies = initialize_strategies(params)
self.variant_definitions = initialize_variant_definitions(params)
end

def to_s
Expand Down Expand Up @@ -64,23 +54,29 @@ def am_enabled?(context, default_result)
result =
if self.enabled
self.strategies.empty? ||
self.strategies.any?{ |s| strategy_enabled?(s, context) }
self.strategies.any? do |s|
strategy_enabled?(s, context) && strategy_constraint_matches?(s, context)
end
else
default_result
end

Unleash.logger.debug "FeatureToggle (enabled:#{self.enabled} default_result:#{default_result} " \
"and Strategies combined returned #{result})"
Unleash.logger.debug "Unleash::FeatureToggle (enabled:#{self.enabled} default_result:#{default_result} " \
"and Strategies combined with contraints returned #{result})"

result
end

def strategy_enabled?(strategy, context)
r = Unleash::STRATEGIES.fetch(strategy.name.to_sym, :unknown).is_enabled?(strategy.params, context)
Unleash.logger.debug "Strategy #{strategy.name} returned #{r} with context: #{context}" # "for params #{strategy.params} "
Unleash.logger.debug "Unleash::FeatureToggle.strategy_enabled? Strategy #{strategy.name} returned #{r} with context: #{context}"
r
end

def strategy_constraint_matches?(strategy, context)
strategy.constraints.empty? || strategy.constraints.all?{ |c| c.matches_context?(context) }
end

def disabled_variant
Unleash::Variant.new(name: 'disabled', enabled: false)
end
Expand Down Expand Up @@ -127,5 +123,36 @@ def ensure_valid_context(context)
end
context
end

def initialize_strategies(params)
params.fetch('strategies', [])
.select{ |s| s.has_key?('name') && Unleash::STRATEGIES.has_key?(s['name'].to_sym) }
.map do |s|
ActivationStrategy.new(
s['name'],
s['parameters'],
(s['constraints'] || []).map do |c|
Constraint.new(
c.fetch('contextName'),
c.fetch('operator'),
c.fetch('values')
)
end
)
end || []
end

def initialize_variant_definitions(params)
(params.fetch('variants', []) || [])
.select{ |v| v.is_a?(Hash) && v.has_key?('name') }
.map do |v|
VariantDefinition.new(
v.fetch('name', ''),
v.fetch('weight', 0),
v.fetch('payload', nil),
v.fetch('overrides', [])
)
end || []
end
end
end
56 changes: 56 additions & 0 deletions lib/unleash/strategy/flexible_rollout.rb
@@ -0,0 +1,56 @@
require 'unleash/strategy/util'

module Unleash
module Strategy
class FlexibleRollout < Base
def name
'flexibleRollout'
end

# need: params['percentage']
def is_enabled?(params = {}, context = nil)
return false unless params.is_a?(Hash)
return false unless context.class.name == 'Unleash::Context'

stickiness = params.fetch('stickiness', 'default')
stickiness_id = resolve_stickiness(stickiness, context)

begin
percentage = Integer(params.fetch('rollout', 0))
percentage = 0 if percentage > 100 || percentage.negative?
rescue ArgumentError
return false
end

group_id = params.fetch('groupId', '')
normalized_user_id = Util.get_normalized_number(stickiness_id, group_id)

return false if stickiness_id.nil?

(percentage.positive? && normalized_user_id <= percentage)
end

private

def random
Random.rand(0..100)
# (rand() * 100).to_s
end

def resolve_stickiness(stickiness, context)
case stickiness
when 'userId'
context.user_id
when 'sessionId'
context.session_id
when 'random'
random
when 'default'
context.user_id || context.session_id || random
else
nil
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/unleash/strategy/gradual_rollout_sessionid.rb
Expand Up @@ -11,7 +11,7 @@ def name
def is_enabled?(params = {}, context = nil)
return false unless params.is_a?(Hash) && params.has_key?('percentage')
return false unless context.class.name == 'Unleash::Context'
return false if context.session_id.empty?
return false if context.session_id.nil? || context.session_id.empty?

percentage = Integer(params['percentage'] || 0)
(percentage.positive? && Util.get_normalized_number(context.session_id, params['groupId'] || "") <= percentage)
Expand Down
4 changes: 2 additions & 2 deletions lib/unleash/strategy/gradual_rollout_userid.rb
Expand Up @@ -8,10 +8,10 @@ def name
end

# need: params['percentage'], params['groupId'], context.user_id,
def is_enabled?(params = {}, context = nil)
def is_enabled?(params = {}, context = nil, _constraints = [])
return false unless params.is_a?(Hash) && params.has_key?('percentage')
return false unless context.class.name == 'Unleash::Context'
return false if context.user_id.empty?
return false if context.user_id.nil? || context.user_id.empty?

percentage = Integer(params['percentage'] || 0)
(percentage.positive? && Util.get_normalized_number(context.user_id, params['groupId'] || "") <= percentage)
Expand Down
12 changes: 9 additions & 3 deletions spec/unleash/activation_strategy_spec.rb
@@ -1,36 +1,42 @@
require 'spec_helper'
require 'unleash/constraint'

RSpec.describe Unleash::ActivationStrategy do
before do
Unleash.configuration = Unleash::Configuration.new
Unleash.logger = Unleash.configuration.logger
end

let(:name) { 'test name' }

describe '#initialize' do
context 'with correct payload' do
let(:params) { Hash.new(test: true) }
let(:constraints) { [Unleash::Constraint.new("constraint_name", "IN", ["value"])] }

it 'initializes with correct attributes' do
expect(Unleash.logger).to_not receive(:warn)

strategy = Unleash::ActivationStrategy.new(name, params)
strategy = Unleash::ActivationStrategy.new(name, params, constraints)

expect(strategy.name).to eq name
expect(strategy.params).to eq params
expect(strategy.constraints).to eq constraints
end
end

context 'with incorrect payload' do
let!(:params) { 'bad_params' }
let(:params) { 'bad_params' }
let(:constraints) { [] }

it 'initializes with correct attributes and logs warning' do
expect(Unleash.logger).to receive(:warn)

strategy = Unleash::ActivationStrategy.new(name, params)
strategy = Unleash::ActivationStrategy.new(name, params, constraints)

expect(strategy.name).to eq name
expect(strategy.params).to eq({})
expect(strategy.constraints).to eq(constraints)
end
end
end
Expand Down

0 comments on commit d11c329

Please sign in to comment.