diff options
Diffstat (limited to 'lib/feature')
-rw-r--r-- | lib/feature/definition.rb | 137 | ||||
-rw-r--r-- | lib/feature/shared.rb | 33 |
2 files changed, 170 insertions, 0 deletions
diff --git a/lib/feature/definition.rb b/lib/feature/definition.rb new file mode 100644 index 00000000000..b0ea55c5805 --- /dev/null +++ b/lib/feature/definition.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +class Feature + class Definition + include ::Feature::Shared + + attr_reader :path + attr_reader :attributes + + PARAMS.each do |param| + define_method(param) do + attributes[param] + end + end + + def initialize(path, opts = {}) + @path = path + @attributes = {} + + # assign nil, for all unknown opts + PARAMS.each do |param| + @attributes[param] = opts[param] + end + end + + def key + name.to_sym + end + + def validate! + unless name.present? + raise Feature::InvalidFeatureFlagError, "Feature flag is missing name" + end + + unless path.present? + raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' is missing path" + end + + unless type.present? + raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' is missing type. Ensure to update #{path}" + end + + unless Definition::TYPES.include?(type.to_sym) + raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' type '#{type}' is invalid. Ensure to update #{path}" + end + + unless File.basename(path, ".yml") == name + raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' has an invalid path: '#{path}'. Ensure to update #{path}" + end + + unless File.basename(File.dirname(path)) == type + raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' has an invalid type: '#{path}'. Ensure to update #{path}" + end + + if default_enabled.nil? + raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' is missing default_enabled. Ensure to update #{path}" + end + end + + def valid_usage!(type_in_code:, default_enabled_in_code:) + unless Array(type).include?(type_in_code.to_s) + # Raise exception in test and dev + raise Feature::InvalidFeatureFlagError, "The `type:` of `#{key}` is not equal to config: " \ + "#{type_in_code} vs #{type}. Ensure to use valid type in #{path} or ensure that you use " \ + "a valid syntax: #{TYPES.dig(type, :example)}" + end + + # We accept an array of defaults as some features are undefined + # and have `default_enabled: true/false` + unless Array(default_enabled).include?(default_enabled_in_code) + # Raise exception in test and dev + raise Feature::InvalidFeatureFlagError, "The `default_enabled:` of `#{key}` is not equal to config: " \ + "#{default_enabled_in_code} vs #{default_enabled}. Ensure to update #{path}" + end + end + + def to_h + attributes + end + + class << self + def paths + @paths ||= [Rails.root.join('config', 'feature_flags', '**', '*.yml')] + end + + def definitions + @definitions ||= {} + end + + def load_all! + definitions.clear + + paths.each do |glob_path| + load_all_from_path!(glob_path) + end + + definitions + end + + def valid_usage!(key, type:, default_enabled:) + if definition = definitions[key.to_sym] + definition.valid_usage!(type_in_code: type, default_enabled_in_code: default_enabled) + elsif type_definition = self::TYPES[type] + raise InvalidFeatureFlagError, "Missing feature definition for `#{key}`" unless type_definition[:optional] + else + raise InvalidFeatureFlagError, "Unknown feature flag type used: `#{type}`" + end + end + + private + + def load_from_file(path) + definition = File.read(path) + definition = YAML.safe_load(definition) + definition.deep_symbolize_keys! + + self.new(path, definition).tap(&:validate!) + rescue => e + raise Feature::InvalidFeatureFlagError, "Invalid definition for `#{path}`: #{e.message}" + end + + def load_all_from_path!(glob_path) + Dir.glob(glob_path).each do |path| + definition = load_from_file(path) + + if previous = definitions[definition.key] + raise InvalidFeatureFlagError, "Feature flag '#{definition.key}' is already defined in '#{previous.path}'" + end + + definitions[definition.key] = definition + end + end + end + end +end + +Feature::Definition.prepend_if_ee('EE::Feature::Definition') diff --git a/lib/feature/shared.rb b/lib/feature/shared.rb new file mode 100644 index 00000000000..14efbb07100 --- /dev/null +++ b/lib/feature/shared.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# This file can contain only simple constructs as it is shared between: +# 1. `Pure Ruby`: `bin/feature-flag` +# 2. `GitLab Rails`: `lib/feature/definition.rb` + +class Feature + module Shared + # optional: defines if a on-disk definition is required for this feature flag type + # rollout_issue: defines if `bin/feature-flag` asks for rollout issue + # example: usage being shown when exception is raised + TYPES = { + development: { + description: 'Short lived, used to enable unfinished code to be deployed', + optional: true, + rollout_issue: true, + example: <<-EOS + Feature.enabled?(:my_feature_flag) + Feature.enabled?(:my_feature_flag, type: :development) + EOS + } + }.freeze + + PARAMS = %i[ + name + default_enabled + type + introduced_by_url + rollout_issue_url + group + ].freeze + end +end |