summaryrefslogtreecommitdiff
path: root/lib/feature/definition.rb
blob: 0ba1bdc47990e6a82aa26f046a9eb7124b3c591d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# 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
        # We lazily load all definitions
        # The hot reloading might request a feature flag
        # before we can properly call `load_all!`
        @definitions ||= load_all!
      end

      def reload!
        @definitions = load_all!
      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

      def register_hot_reloader!
        # Reload feature flags on change of this file or any `.yml`
        file_watcher = Rails.configuration.file_watcher.new(reload_files, reload_directories) do
          Feature::Definition.reload!
        end

        Rails.application.reloaders << file_watcher
        Rails.application.reloader.to_run { file_watcher.execute_if_updated }

        file_watcher
      end

      private

      def load_all!
        # We currently do not load feature flag definitions
        # in production environments
        return [] unless Gitlab.dev_or_test_env?

        paths.each_with_object({}) do |glob_path, definitions|
          load_all_from_path!(definitions, glob_path)
        end
      end

      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!(definitions, 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

      def reload_files
        []
      end

      def reload_directories
        paths.each_with_object({}) do |path, result|
          path = File.dirname(path)
          Dir.glob(path).each do |matching_dir|
            result[matching_dir] = 'yml'
          end
        end
      end
    end
  end
end

Feature::Definition.prepend_if_ee('EE::Feature::Definition')