Commit 0db7b48f authored by Stan Hu's avatar Stan Hu

Merge branch 'userid-ff' into 'master'

Support Feature Flag userWithId Strategy on Backend

See merge request gitlab-org/gitlab-ee!14752
parents 3d8c4b6b 0a82e441
...@@ -101,14 +101,14 @@ class Projects::FeatureFlagsController < Projects::ApplicationController ...@@ -101,14 +101,14 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
params.require(:operations_feature_flag) params.require(:operations_feature_flag)
.permit(:name, :description, :active, .permit(:name, :description, :active,
scopes_attributes: [:environment_scope, :active, scopes_attributes: [:environment_scope, :active,
strategies: [:name, parameters: [:groupId, :percentage]]]) strategies: [:name, parameters: [:groupId, :percentage, :userIds]]])
end end
def update_params def update_params
params.require(:operations_feature_flag) params.require(:operations_feature_flag)
.permit(:name, :description, :active, .permit(:name, :description, :active,
scopes_attributes: [:id, :environment_scope, :active, :_destroy, scopes_attributes: [:id, :environment_scope, :active, :_destroy,
strategies: [:name, parameters: [:groupId, :percentage]]]) strategies: [:name, parameters: [:groupId, :percentage, :userIds]]])
end end
def feature_flag_json(feature_flag) def feature_flag_json(feature_flag)
......
...@@ -3,6 +3,14 @@ ...@@ -3,6 +3,14 @@
class FeatureFlagStrategiesValidator < ActiveModel::EachValidator class FeatureFlagStrategiesValidator < ActiveModel::EachValidator
STRATEGY_DEFAULT = 'default'.freeze STRATEGY_DEFAULT = 'default'.freeze
STRATEGY_GRADUALROLLOUTUSERID = 'gradualRolloutUserId'.freeze STRATEGY_GRADUALROLLOUTUSERID = 'gradualRolloutUserId'.freeze
STRATEGY_USERWITHID = 'userWithId'.freeze
# Order key names alphabetically
STRATEGIES = {
STRATEGY_DEFAULT => [].freeze,
STRATEGY_GRADUALROLLOUTUSERID => %w[groupId percentage].freeze,
STRATEGY_USERWITHID => ['userIds'].freeze
}.freeze
USERID_MAX_LENGTH = 256
def validate_each(record, attribute, value) def validate_each(record, attribute, value)
return unless value return unless value
...@@ -12,20 +20,40 @@ class FeatureFlagStrategiesValidator < ActiveModel::EachValidator ...@@ -12,20 +20,40 @@ class FeatureFlagStrategiesValidator < ActiveModel::EachValidator
strategy_validations(record, attribute, strategy) strategy_validations(record, attribute, strategy)
end end
else else
record.errors.add(attribute, 'must be an array of strategy hashes') error(record, attribute, 'must be an array of strategy hashes')
end end
end end
private private
def strategy_validations(record, attribute, strategy) def strategy_validations(record, attribute, strategy)
validate_name(record, attribute, strategy) &&
validate_parameters_type(record, attribute, strategy) &&
validate_parameters_keys(record, attribute, strategy) &&
validate_parameters_values(record, attribute, strategy)
end
def validate_name(record, attribute, strategy)
STRATEGIES.key?(strategy['name']) || error(record, attribute, 'strategy name is invalid')
end
def validate_parameters_type(record, attribute, strategy)
strategy['parameters'].is_a?(Hash) || error(record, attribute, 'parameters are invalid')
end
def validate_parameters_keys(record, attribute, strategy)
name, parameters = strategy.values_at('name', 'parameters')
actual_keys = parameters.keys.sort
expected_keys = STRATEGIES[name]
expected_keys == actual_keys || error(record, attribute, 'parameters are invalid')
end
def validate_parameters_values(record, attribute, strategy)
case strategy['name'] case strategy['name']
when STRATEGY_DEFAULT
default_parameters_validation(record, attribute, strategy)
when STRATEGY_GRADUALROLLOUTUSERID when STRATEGY_GRADUALROLLOUTUSERID
gradual_rollout_user_id_parameters_validation(record, attribute, strategy) gradual_rollout_user_id_parameters_validation(record, attribute, strategy)
else when STRATEGY_USERWITHID
record.errors.add(attribute, 'strategy name is invalid') user_with_id_parameters_validation(record, attribute, strategy)
end end
end end
...@@ -34,17 +62,34 @@ class FeatureFlagStrategiesValidator < ActiveModel::EachValidator ...@@ -34,17 +62,34 @@ class FeatureFlagStrategiesValidator < ActiveModel::EachValidator
group_id = strategy.dig('parameters', 'groupId') group_id = strategy.dig('parameters', 'groupId')
unless percentage.is_a?(String) && percentage.match(/\A[1-9]?[0-9]\z|\A100\z/) unless percentage.is_a?(String) && percentage.match(/\A[1-9]?[0-9]\z|\A100\z/)
record.errors.add(attribute, 'percentage must be a string between 0 and 100 inclusive') error(record, attribute, 'percentage must be a string between 0 and 100 inclusive')
end end
unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/) unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/)
record.errors.add(attribute, 'groupId parameter is invalid') error(record, attribute, 'groupId parameter is invalid')
end
end
def user_with_id_parameters_validation(record, attribute, strategy)
user_ids = strategy.dig('parameters', 'userIds')
unless user_ids.is_a?(String) && !user_ids.match(/[\n\r\t]|,,/) && valid_ids?(user_ids.split(","))
error(record, attribute, "userIds must be a string of unique comma separated values each #{USERID_MAX_LENGTH} characters or less")
end end
end end
def default_parameters_validation(record, attribute, strategy) def valid_ids?(user_ids)
unless strategy['parameters'] == {} user_ids.uniq.length == user_ids.length &&
record.errors.add(attribute, 'parameters must be empty for default strategy') user_ids.all? { |id| valid_id?(id) }
end end
def valid_id?(user_id)
user_id.present? &&
user_id.strip == user_id &&
user_id.length <= USERID_MAX_LENGTH
end
def error(record, attribute, msg)
record.errors.add(attribute, msg)
false
end end
end end
---
title: Support feature flag userWithId strategy on backend
merge_request: 14752
author:
type: added
...@@ -440,6 +440,30 @@ describe Projects::FeatureFlagsController do ...@@ -440,6 +440,30 @@ describe Projects::FeatureFlagsController do
end end
end end
context 'when creates additional scope with a userWithId strategy' do
it 'creates a strategy for the scope' do
params = view_params.merge({
operations_feature_flag: {
name: 'my_feature_flag',
active: true,
scopes_attributes: [{ environment_scope: '*', active: true },
{ environment_scope: 'production', active: false,
strategies: [{ name: 'userWithId',
parameters: { userIds: '123,4,6722' } }] }]
}
})
post(:create, params: params, format: :json)
expect(response).to have_gitlab_http_status(:ok)
production_strategies_json = json_response['scopes'].second['strategies']
expect(production_strategies_json).to eq([{
'name' => 'userWithId',
'parameters' => { "userIds" => "123,4,6722" }
}])
end
end
context 'when creates an additional scope without a strategy' do context 'when creates an additional scope without a strategy' do
it 'creates a default strategy' do it 'creates a default strategy' do
params = view_params.merge({ params = view_params.merge({
...@@ -774,6 +798,22 @@ describe Projects::FeatureFlagsController do ...@@ -774,6 +798,22 @@ describe Projects::FeatureFlagsController do
}]) }])
end end
it 'creates a userWithId strategy' do
scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
params = request_params(scope, [{ name: 'userWithId', parameters: { userIds: 'sam,fred' } }])
put(:update, params: params, format: :json, as: :json)
expect(response).to have_gitlab_http_status(:ok)
scope_json = json_response['scopes'].select do |s|
s['environment_scope'] == 'production'
end.first
expect(scope_json['strategies']).to eq([{
"name" => "userWithId",
"parameters" => { "userIds" => "sam,fred" }
}])
end
it 'updates an existing strategy' do it 'updates an existing strategy' do
scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }]) scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
params = request_params(scope, [{ name: 'gradualRolloutUserId', params = request_params(scope, [{ name: 'gradualRolloutUserId',
...@@ -807,6 +847,30 @@ describe Projects::FeatureFlagsController do ...@@ -807,6 +847,30 @@ describe Projects::FeatureFlagsController do
expect(scope_json['strategies']).to eq([]) expect(scope_json['strategies']).to eq([])
end end
it 'accepts multiple strategies' do
scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
params = request_params(scope, [
{ name: 'gradualRolloutUserId', parameters: { groupId: 'mygroup', percentage: '55' } },
{ name: 'userWithId', parameters: { userIds: 'joe' } }
])
put(:update, params: params, format: :json, as: :json)
expect(response).to have_gitlab_http_status(:ok)
scope_json = json_response['scopes'].select do |s|
s['environment_scope'] == 'production'
end.first
expect(scope_json['strategies'].length).to eq(2)
expect(scope_json['strategies']).to include({
"name" => "gradualRolloutUserId",
"parameters" => { "groupId" => "mygroup", "percentage" => "55" }
})
expect(scope_json['strategies']).to include({
"name" => "userWithId",
"parameters" => { "userIds" => "joe" }
})
end
it 'does not modify strategies when there is no strategies key in the params' do it 'does not modify strategies when there is no strategies key in the params' do
scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }]) scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
params = { params = {
...@@ -853,6 +917,16 @@ describe Projects::FeatureFlagsController do ...@@ -853,6 +917,16 @@ describe Projects::FeatureFlagsController do
"parameters" => { "groupId" => "default", "percentage" => "10" } "parameters" => { "groupId" => "default", "percentage" => "10" }
}]) }])
end end
it 'does not accept extra parameters in the strategy params' do
scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
params = request_params(scope, [{ name: 'userWithId', parameters: { userIds: 'joe', groupId: 'default' } }])
put(:update, params: params, format: :json, as: :json)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq(["Scopes strategies parameters are invalid"])
end
end end
end end
......
...@@ -89,6 +89,7 @@ describe Operations::FeatureFlagScope do ...@@ -89,6 +89,7 @@ describe Operations::FeatureFlagScope do
where(:name, :params, :expected) do where(:name, :params, :expected) do
'default' | {} | [] 'default' | {} | []
'gradualRolloutUserId' | { groupId: 'mygroup', percentage: '50' } | [] 'gradualRolloutUserId' | { groupId: 'mygroup', percentage: '50' } | []
'userWithId' | { userIds: 'sam' } | []
5 | nil | ['strategy name is invalid'] 5 | nil | ['strategy name is invalid']
nil | nil | ['strategy name is invalid'] nil | nil | ['strategy name is invalid']
"nothing" | nil | ['strategy name is invalid'] "nothing" | nil | ['strategy name is invalid']
...@@ -98,7 +99,7 @@ describe Operations::FeatureFlagScope do ...@@ -98,7 +99,7 @@ describe Operations::FeatureFlagScope do
[] | nil | ['strategy name is invalid'] [] | nil | ['strategy name is invalid']
end end
with_them do with_them do
it 'must be one of "default" or "gradualRolloutUserId"' do it 'must be one of "default", "gradualRolloutUserId", or "userWithId"' do
feature_flag = create(:operations_feature_flag) feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag, strategies: [{ name: name, parameters: params }]) scope = described_class.create(feature_flag: feature_flag, strategies: [{ name: name, parameters: params }])
...@@ -109,6 +110,38 @@ describe Operations::FeatureFlagScope do ...@@ -109,6 +110,38 @@ describe Operations::FeatureFlagScope do
describe 'parameters' do describe 'parameters' do
context 'when the strategy name is gradualRolloutUserId' do context 'when the strategy name is gradualRolloutUserId' do
it 'must have parameters' do
feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag,
strategies: [{ name: 'gradualRolloutUserId' }])
expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
end
where(:invalid_parameters) do
[nil, {}, { percentage: '40', groupId: 'mygroup', userIds: '4' }, { percentage: '40' },
{ percentage: '40', groupId: 'mygroup', extra: nil }, { groupId: 'mygroup' }]
end
with_them do
it 'must have valid parameters for the strategy' do
feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag,
strategies: [{ name: 'gradualRolloutUserId',
parameters: invalid_parameters }])
expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
end
end
it 'allows the parameters in any order' do
feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag,
strategies: [{ name: 'gradualRolloutUserId',
parameters: { percentage: '10', groupId: 'mygroup' } }])
expect(scope.errors[:strategies]).to be_empty
end
describe 'percentage' do describe 'percentage' do
where(:invalid_value) do where(:invalid_value) do
[50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100", [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100",
...@@ -163,25 +196,83 @@ describe Operations::FeatureFlagScope do ...@@ -163,25 +196,83 @@ describe Operations::FeatureFlagScope do
with_them do with_them do
it 'must be a string value of up to 32 lowercase characters' do it 'must be a string value of up to 32 lowercase characters' do
feature_flag = create(:operations_feature_flag) feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag, strategies: [{ name: 'gradualRolloutUserId', scope = described_class.create(feature_flag: feature_flag,
strategies: [{ name: 'gradualRolloutUserId',
parameters: { groupId: valid_value, percentage: '40' } }]) parameters: { groupId: valid_value, percentage: '40' } }])
expect(scope.errors[:strategies]).to eq([]) expect(scope.errors[:strategies]).to eq([])
end end
end end
end end
end
context 'when the strategy name is userWithId' do
it 'must have parameters' do it 'must have parameters' do
feature_flag = create(:operations_feature_flag) feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag, strategies: [{ name: 'gradualRolloutUserId' }]) scope = described_class.create(feature_flag: feature_flag, strategies: [{ name: 'userWithId' }])
expect(scope.errors[:strategies]).to include('groupId parameter is invalid')
expect(scope.errors[:strategies]).to include('percentage must be a string between 0 and 100 inclusive') expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
end
where(:invalid_parameters) do
[nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}]
end
with_them do
it 'must have valid parameters for the strategy' do
feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag, strategies: [{ name: 'userWithId',
parameters: invalid_parameters }])
expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
end
end
describe 'userIds' do
where(:valid_value) do
["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike",
"gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0",
"$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed",
"a" * 256, "a,#{'b' * 256},ccc", "many spaces"]
end
with_them do
it 'is valid with a string of comma separated values' do
feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag,
strategies: [{ name: 'userWithId', parameters: { userIds: valid_value } }])
expect(scope.errors[:strategies]).to be_empty
end
end
where(:invalid_value) do
[1, 2.5, {}, [], nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r",
"joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ",
" ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"]
end
with_them do
it 'is invalid' do
feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag,
strategies: [{ name: 'userWithId', parameters: { userIds: invalid_value } }])
expect(scope.errors[:strategies]).to include(
'userIds must be a string of unique comma separated values each 256 characters or less'
)
end
end
end end
end end
context 'when the strategy name is default' do context 'when the strategy name is default' do
it 'must have parameters' do
feature_flag = create(:operations_feature_flag)
scope = described_class.create(feature_flag: feature_flag, strategies: [{ name: 'default' }])
expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
end
where(:invalid_value) do where(:invalid_value) do
[{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil] [{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil, [], 2.5]
end end
with_them do with_them do
it 'must be empty' do it 'must be empty' do
...@@ -190,7 +281,7 @@ describe Operations::FeatureFlagScope do ...@@ -190,7 +281,7 @@ describe Operations::FeatureFlagScope do
strategies: [{ name: 'default', strategies: [{ name: 'default',
parameters: invalid_value }]) parameters: invalid_value }])
expect(scope.errors[:strategies]).to eq(['parameters must be empty for default strategy']) expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
end end
end end
...@@ -200,7 +291,7 @@ describe Operations::FeatureFlagScope do ...@@ -200,7 +291,7 @@ describe Operations::FeatureFlagScope do
strategies: [{ name: 'default', strategies: [{ name: 'default',
parameters: {} }]) parameters: {} }])
expect(scope.errors[:strategies]).to eq([]) expect(scope.errors[:strategies]).to be_empty
end end
end end
end end
......
...@@ -213,6 +213,36 @@ describe API::Unleash do ...@@ -213,6 +213,36 @@ describe API::Unleash do
strategies = json_response['features'].first['strategies'] strategies = json_response['features'].first['strategies']
expect(strategies).to eq([{ "name" => "default", "parameters" => {} }]) expect(strategies).to eq([{ "name" => "default", "parameters" => {} }])
end end
it 'returns multiple strategies for a feature flag' do
client = create(:operations_feature_flags_client, project: project)
feature_flag = create(:operations_feature_flag, project: project, name: 'feature1', active: true)
create(:operations_feature_flag_scope,
feature_flag: feature_flag,
environment_scope: 'staging',
active: true,
strategies: [{ name: "userWithId", parameters: { userIds: "max,fred" } },
{ name: "gradualRolloutUserId",
parameters: { groupId: "default", percentage: "50" } }])
headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "staging" }
get api(features_url), headers: headers
expect(response).to have_gitlab_http_status(:ok)
strategies = json_response['features'].first['strategies'].sort_by { |s| s['name'] }
expect(strategies).to eq([{
"name" => "gradualRolloutUserId",
"parameters" => {
"percentage" => "50",
"groupId" => "default"
}
}, {
"name" => "userWithId",
"parameters" => {
"userIds" => "max,fred"
}
}])
end
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment