release_feature 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFeature
4
+ class Client
5
+ attr_reader :updated_at
6
+
7
+ # @param [String, Symbol] environment
8
+ # @param [ActiveRecordRepository, YamlRecordRepository, HashRecordRepository] base_repository
9
+ # @param [nil] extra_repository
10
+ # @param [Integer] refresh_interval_sec
11
+ def initialize(environment:, base_repository:, extra_repository: nil, refresh_interval_sec: 600)
12
+ @environment = environment.to_sym
13
+ @base_repository = base_repository
14
+ @extra_repository = extra_repository
15
+ @refresh_interval_sec = if refresh_interval_sec.is_a?(Integer)
16
+ refresh_interval_sec
17
+ else
18
+ raise ReleaseFeature::Error, 'refresh_interval_sec must be Integer'
19
+ end
20
+ load_features
21
+ end
22
+
23
+ # @param [Symbol] feature_name
24
+ # @param [TrueClass, FalseClass] time
25
+ def permitted?(feature_name, time: nil)
26
+ time ||= Time.now
27
+
28
+ refresh if expired?(time)
29
+
30
+ name = feature_name.to_sym
31
+ raise ReleaseFeature::Error, "#{feature_name} is not registered." if @features[feature_name.to_sym].nil?
32
+
33
+ feature = to_feature(@features, name, @environment)
34
+ return false if feature.nil?
35
+
36
+ feature.permitted?(time)
37
+ end
38
+
39
+ def current_features
40
+ @features
41
+ end
42
+
43
+ def refresh
44
+ load_features
45
+ true
46
+ rescue StandardError => e
47
+ e.message
48
+ # errorがある場合は@featuresを更新しない
49
+ false
50
+ end
51
+
52
+ def names
53
+ @features.names
54
+ end
55
+
56
+ def environments
57
+ @features.environments
58
+ end
59
+
60
+ private
61
+
62
+ def to_feature(features, name, environment)
63
+ features.dig(name, environment)
64
+ end
65
+
66
+ def expired?(time = nil)
67
+ time ||= Time.now
68
+ raise ReleaseFeature::Error, 'current_time is must be Time class' unless time.is_a?(Time)
69
+
70
+ @updated_at + @refresh_interval_sec < time
71
+ end
72
+
73
+ def load_features
74
+ features = if @extra_repository
75
+ @base_repository.features.combine_features(@extra_repository.features)
76
+ else
77
+ @base_repository.features
78
+ end
79
+ @features = features
80
+ @updated_at = Time.now
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,20 @@
1
+ timezone: JST
2
+ features:
3
+ hoge_feature:
4
+ development:
5
+ open_at: 2000-01-01 00:00
6
+ close_at: 2999-12-31 23:59
7
+ staging:
8
+ open_at: 2021-11-01 10:00
9
+ close_at: 2999-12-31 23:59
10
+ production:
11
+ open_at: 2021-12-01 10:00
12
+ close_at: 2999-12-31 23:59
13
+ puyo_feature:
14
+ development:
15
+ open_at: 2000-01-01 00:00
16
+ close_at: 2999-12-31 23:59
17
+ production:
18
+ open_at: 2022-12-01 10:00
19
+ close_at: 2999-12-31 23:59
20
+
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFeature
4
+ class Feature
5
+ attr_reader :name, :environment, :open_at, :close_at, :errors
6
+
7
+ # @param [String, Symbol] name
8
+ # @param [String, Symbol] environment
9
+ # @param [Time] open_at
10
+ # @param [Time] close_at
11
+ def initialize(name:, environment:, open_at:, close_at:)
12
+ validate_present({ name: name, environment: environment, open_at: open_at, close_at: close_at })
13
+ @name = cast_to_sym(:name, name)
14
+ @environment = cast_to_sym(:environment, environment)
15
+ @open_at = open_at
16
+ @close_at = close_at
17
+ raise ReleaseFeature::Error, errors_full_message unless valid?
18
+ end
19
+
20
+ # @param [Time] current_time
21
+ # @return [TrueClass, FalseClass]
22
+ def permitted?(current_time)
23
+ (open_at...close_at).cover?(current_time)
24
+ end
25
+
26
+ private
27
+
28
+ # @return [TrueClass, FalseClass]
29
+ def valid?
30
+ initialize_errors
31
+ validate_all
32
+ @errors.size.zero?
33
+ end
34
+
35
+ def validate_present(attrs)
36
+ attrs.each do |k, v|
37
+ if v.nil?
38
+ raise ReleaseFeature::Error,
39
+ "#{k} must be present."
40
+ end
41
+ end
42
+ end
43
+
44
+ def validate_all
45
+ validate_name
46
+ validate_open_at
47
+ validate_close_at
48
+ validate_range
49
+ end
50
+
51
+ def errors_full_message
52
+ @errors.join(' ')
53
+ end
54
+
55
+ def initialize_errors
56
+ @errors = []
57
+ end
58
+
59
+ # @param [String, Symbol] attr_name
60
+ # @param [Object] val
61
+ # @param [String] message
62
+ def add_error(attr_name, val, message)
63
+ @errors << "#{val} of #{attr_name} has error. #{message}"
64
+ end
65
+
66
+ def validate_name
67
+ message = name_error_message(name)
68
+ return unless message
69
+
70
+ add_error('name', name, message)
71
+ end
72
+
73
+ def validate_open_at
74
+ return if time_present?(open_at)
75
+
76
+ add_error('open_at', open_at, 'set time to open_at.')
77
+ end
78
+
79
+ def validate_close_at
80
+ return if time_present?(close_at)
81
+
82
+ add_error('close_at', close_at, 'set time to close_at.')
83
+ end
84
+
85
+ def validate_range
86
+ return if open_at < close_at
87
+
88
+ add_error('open_at', open_at, 'open_at is less than close_at.')
89
+ add_error('close_at', close_at, 'close_at is more than open_at.')
90
+ end
91
+
92
+ # @param [String] attr
93
+ def time_present?(attr)
94
+ attr.is_a?(Time)
95
+ end
96
+
97
+ # @param [Symbol] key
98
+ # @param [String, Symbol] str
99
+ def cast_to_sym(key, str)
100
+ raise ReleaseFeature::Error, "#{key} must be String or Symbol" unless str.is_a?(String) || str.is_a?(Symbol)
101
+
102
+ str.to_sym
103
+ end
104
+
105
+ # @param [String, Symbol] name
106
+ # @return [String, NilClass]
107
+ def name_error_message(name)
108
+ if name.to_s.match?(/\s/)
109
+ 'space is not permitted to name.'
110
+ elsif name.to_s.size < 8
111
+ 'more than 7 character is permitted to name.'
112
+ elsif !name.to_s.match?(/^[a-z]([a-z0-9_]{7,})$/)
113
+ ' 0-9 or a-z or _ is permitted to name.'
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFeature
4
+ class FeatureCollection < Hash
5
+ # @param [ReleaseFeature::FeatureCollection] features
6
+ # @return [ReleaseFeature::FeatureCollection]
7
+ def combine_features(features)
8
+ all_names = (names + features.names).uniq
9
+ all_environments = (environments + features.environments).uniq
10
+ all_names.each_with_object(ReleaseFeature::FeatureCollection.new).each do |name, memo_obj|
11
+ memo_obj[name] = build_environments(name, all_environments, features)
12
+ end
13
+ end
14
+
15
+ # @return [Array<Symbol>]
16
+ def names
17
+ keys
18
+ end
19
+
20
+ # @return [Array<Symbol>]
21
+ def environments
22
+ values.each_with_object([]) { |a, memo| memo.concat(a.keys) }.uniq
23
+ end
24
+
25
+ private
26
+
27
+ # @param [:Symbol] name
28
+ # @param [:Symbol] environments
29
+ # @param [ReleaseFeature::FeatureCollection] features
30
+ # @return [Hash{Symbol->Hash}]
31
+ def build_environments(name, environments, features)
32
+ environments.each_with_object({}) do |environment, memo_obj|
33
+ next unless build_feature(name, environment, features)
34
+
35
+ memo_obj[environment] = build_feature(name, environment, features)
36
+ end
37
+ end
38
+
39
+ # @param [:Symbol] name
40
+ # @param [:Symbol] environments
41
+ # @param [ReleaseFeature::FeatureCollection] features
42
+ # @return [ReleaseFeature::Feature]
43
+ def build_feature(name, environment, features)
44
+ if features.dig(name, environment)
45
+ features.dig(name, environment)
46
+ elsif dig(name, environment)
47
+ dig(name, environment)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFeature
4
+ module Repository
5
+ class ActiveRecordRepository
6
+ # @param [Class] model
7
+ def initialize(model:)
8
+ @model = model
9
+ end
10
+
11
+ # @return [ReleaseFeature::FeatureCollection]
12
+ def features
13
+ data = @model.all
14
+ convert_to_feature_collection(data)
15
+ end
16
+
17
+ private
18
+
19
+ # @param [ActiveRecord] data
20
+ # @return [ReleaseFeature::FeatureCollection]
21
+ def convert_to_feature_collection(data)
22
+ data.each_with_object(ReleaseFeature::FeatureCollection.new) do |record, memo_obj|
23
+ name_key = record.name.to_sym
24
+ environment_key = record.environment.to_sym
25
+ feature = build_feature(record)
26
+ if memo_obj[name_key].nil?
27
+ memo_obj[name_key] = { environment_key => feature }
28
+ else
29
+ memo_obj[name_key][environment_key] = feature
30
+ end
31
+ end
32
+ end
33
+
34
+ # @param [ActiveRecord] record
35
+ # @return [ReleaseFeature::Feature]
36
+ def build_feature(record)
37
+ ReleaseFeature::Feature.new(
38
+ name: record.name,
39
+ environment: record.environment,
40
+ open_at: record.open_at,
41
+ close_at: record.close_at
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFeature
4
+ module Repository
5
+ class HashRepository
6
+ # @param [Hash] hash
7
+ def initialize(hash:)
8
+ @hash = hash
9
+ end
10
+
11
+ # @return [ReleaseFeature::FeatureCollection]
12
+ def features
13
+ data = @hash
14
+ convert_to_feature_collection(data)
15
+ end
16
+
17
+ private
18
+
19
+ # @param [Hash] data
20
+ # @return [ReleaseFeature::FeatureCollection]
21
+ def convert_to_feature_collection(data)
22
+ data[:features].each_with_object(ReleaseFeature::FeatureCollection.new) do |(name, environments), memo_obj|
23
+ memo_obj[name] = {}
24
+ environments.each do |environment, range|
25
+ memo_obj[name][environment] = build_feature(name, environment, range[:open_at], range[:close_at])
26
+ end
27
+ end
28
+ end
29
+
30
+ # @param [Symbol] name
31
+ # @param [Symbol] environment
32
+ # @param [Time] open_at
33
+ # @param [Time] close_at
34
+ # @return [ReleaseFeature::Feature]
35
+ def build_feature(name, environment, open_at, close_at)
36
+ ReleaseFeature::Feature.new(
37
+ name: name,
38
+ environment: environment,
39
+ open_at: open_at,
40
+ close_at: close_at
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tzinfo'
4
+ module ReleaseFeature
5
+ module Repository
6
+ class YamlRepository
7
+ class AccessibleRange
8
+ attr_reader :open_at, :close_at
9
+
10
+ def initialize(open_at:, close_at:)
11
+ @open_at = open_at
12
+ @close_at = close_at
13
+ end
14
+ end
15
+
16
+ # @param [String] path
17
+ def initialize(path:)
18
+ @path = path
19
+ end
20
+
21
+ # @return [ReleaseFeature::FeatureCollection]
22
+ def features
23
+ data = YAML.load_file(@path)
24
+ convert_to_feature_collection(data)
25
+ end
26
+
27
+ private
28
+
29
+ # @param [Hash] data
30
+ # @return [ReleaseFeature::FeatureCollection]
31
+ def convert_to_feature_collection(data)
32
+ timezone = data['timezone']
33
+ features = data['features']
34
+ symbolize_features(features, timezone)
35
+ end
36
+
37
+ # @param [Hash] features
38
+ # @return [ReleaseFeature::FeatureCollection]
39
+ def symbolize_features(features, timezone)
40
+ features.each_with_object(ReleaseFeature::FeatureCollection.new) do |(name, environments), memo_obj|
41
+ environments.each do |environment, range|
42
+ if memo_obj[name.to_sym]
43
+ memo_obj[name.to_sym][environment.to_sym] = build_feature(name, environment, range, timezone)
44
+ else
45
+ memo_obj[name.to_sym] = { environment.to_sym => build_feature(name, environment, range, timezone) }
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # @param [Str] name
52
+ # @param [Str] environment
53
+ # @param [Hash] range
54
+ # @param [String] timezone
55
+ # @return [ReleaseFeature::Feature]
56
+ def build_feature(name, environment, range, timezone)
57
+ accessible_range = to_range_struct(range, timezone)
58
+ ReleaseFeature::Feature.new(
59
+ name: name.to_sym,
60
+ environment: environment.to_sym,
61
+ open_at: accessible_range.open_at,
62
+ close_at: accessible_range.close_at
63
+ )
64
+ end
65
+
66
+ # @param [Hash] range
67
+ # @param [String] timezone
68
+ # @return [AccessibleRange]
69
+ def to_range_struct(range, timezone)
70
+ AccessibleRange.new(
71
+ open_at: cast_to_time(range['open_at'], timezone),
72
+ close_at: cast_to_time(range['close_at'], timezone)
73
+ )
74
+ end
75
+
76
+ # @param [String] str
77
+ # @param [String] timezone
78
+ # @return [Time]
79
+ def cast_to_time(str, timezone)
80
+ time = Time.parse(str)
81
+ year = time.year
82
+ month = time.month
83
+ day = time.day
84
+ hour = time.hour
85
+ minute = time.min
86
+ Time.new(year, month, day, hour, minute, 0, timezone)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFeature
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'yaml'
5
+ require_relative 'release_feature/version'
6
+ require_relative 'release_feature/repository/yaml_repository'
7
+ require_relative 'release_feature/repository/active_record_repository'
8
+ require_relative 'release_feature/repository/hash_repository'
9
+ require_relative 'release_feature/feature'
10
+ require_relative 'release_feature/client'
11
+ require_relative 'release_feature/feature_collection'
12
+
13
+ module ReleaseFeature
14
+ class Error < StandardError; end
15
+ end
@@ -0,0 +1,104 @@
1
+ ---
2
+ sources:
3
+ - name: ruby/gem_rbs_collection
4
+ remote: https://github.com/ruby/gem_rbs_collection.git
5
+ revision: main
6
+ repo_dir: gems
7
+ path: ".gem_rbs_collection"
8
+ gems:
9
+ - name: date
10
+ version: '0'
11
+ source:
12
+ type: stdlib
13
+ - name: time
14
+ version: '0'
15
+ source:
16
+ type: stdlib
17
+ - name: activerecord
18
+ version: '6.1'
19
+ source:
20
+ type: git
21
+ name: ruby/gem_rbs_collection
22
+ revision: 5e006c8976e661f16b61ba9193acce07f82b4ec5
23
+ remote: https://github.com/ruby/gem_rbs_collection.git
24
+ repo_dir: gems
25
+ - name: activemodel
26
+ version: '6.0'
27
+ source:
28
+ type: git
29
+ name: ruby/gem_rbs_collection
30
+ revision: 5e006c8976e661f16b61ba9193acce07f82b4ec5
31
+ remote: https://github.com/ruby/gem_rbs_collection.git
32
+ repo_dir: gems
33
+ - name: activesupport
34
+ version: '6.0'
35
+ source:
36
+ type: git
37
+ name: ruby/gem_rbs_collection
38
+ revision: 5e006c8976e661f16b61ba9193acce07f82b4ec5
39
+ remote: https://github.com/ruby/gem_rbs_collection.git
40
+ repo_dir: gems
41
+ - name: ast
42
+ version: '2.4'
43
+ source:
44
+ type: git
45
+ name: ruby/gem_rbs_collection
46
+ revision: 5e006c8976e661f16b61ba9193acce07f82b4ec5
47
+ remote: https://github.com/ruby/gem_rbs_collection.git
48
+ repo_dir: gems
49
+ - name: listen
50
+ version: '3.2'
51
+ source:
52
+ type: git
53
+ name: ruby/gem_rbs_collection
54
+ revision: 5e006c8976e661f16b61ba9193acce07f82b4ec5
55
+ remote: https://github.com/ruby/gem_rbs_collection.git
56
+ repo_dir: gems
57
+ - name: parallel
58
+ version: '1.20'
59
+ source:
60
+ type: git
61
+ name: ruby/gem_rbs_collection
62
+ revision: 5e006c8976e661f16b61ba9193acce07f82b4ec5
63
+ remote: https://github.com/ruby/gem_rbs_collection.git
64
+ repo_dir: gems
65
+ - name: rainbow
66
+ version: '3.0'
67
+ source:
68
+ type: git
69
+ name: ruby/gem_rbs_collection
70
+ revision: 5e006c8976e661f16b61ba9193acce07f82b4ec5
71
+ remote: https://github.com/ruby/gem_rbs_collection.git
72
+ repo_dir: gems
73
+ - name: yaml
74
+ version: '0'
75
+ source:
76
+ type: stdlib
77
+ - name: monitor
78
+ version: '0'
79
+ source:
80
+ type: stdlib
81
+ - name: singleton
82
+ version: '0'
83
+ source:
84
+ type: stdlib
85
+ - name: logger
86
+ version: '0'
87
+ source:
88
+ type: stdlib
89
+ - name: mutex_m
90
+ version: '0'
91
+ source:
92
+ type: stdlib
93
+ - name: pathname
94
+ version: '0'
95
+ source:
96
+ type: stdlib
97
+ - name: dbm
98
+ version: '0'
99
+ source:
100
+ type: stdlib
101
+ - name: pstore
102
+ version: '0'
103
+ source:
104
+ type: stdlib
@@ -0,0 +1,18 @@
1
+ # Download sources
2
+ sources:
3
+ - name: ruby/gem_rbs_collection
4
+ remote: https://github.com/ruby/gem_rbs_collection.git
5
+ revision: main
6
+ repo_dir: gems
7
+
8
+ # A directory to install the downloaded RBSs
9
+ path: .gem_rbs_collection
10
+
11
+ gems:
12
+ # Skip loading rbs gem's RBS.
13
+ # It's unnecessary if you don't use rbs as a library.
14
+ - name: rbs
15
+ ignore: true
16
+ - name: date
17
+ - name: time
18
+ - name: activerecord
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/release_feature/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'release_feature'
7
+ spec.version = ReleaseFeature::VERSION
8
+ spec.authors = ['junara']
9
+ spec.email = ['jun5araki@gmail.com']
10
+
11
+ spec.summary = 'Release feature in custom date range.'
12
+ spec.description = 'Release feature in custom date range.'
13
+ spec.homepage = 'https://github.com/junara/release_feature'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.7.0')
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = spec.homepage
19
+ spec.metadata['changelog_uri'] = 'https://github.com/junara/release_feature/blob/main/CHANGELOG.md'
20
+
21
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
22
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
23
+ end
24
+ spec.bindir = 'exe'
25
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
26
+ spec.require_paths = ['lib']
27
+ spec.add_development_dependency 'activerecord'
28
+ spec.add_development_dependency 'factory_bot'
29
+ spec.add_development_dependency 'rspec'
30
+ spec.add_development_dependency 'rspec-parameterized'
31
+ spec.add_development_dependency 'rubocop'
32
+ spec.add_development_dependency 'rubocop-performance'
33
+ spec.add_development_dependency 'rubocop-rake'
34
+ spec.add_development_dependency 'rubocop-rspec'
35
+ spec.add_development_dependency 'sqlite3'
36
+ spec.add_development_dependency 'timecop'
37
+ spec.add_dependency 'time'
38
+ spec.add_dependency 'yaml'
39
+ spec.metadata['rubygems_mfa_required'] = 'true'
40
+ end
@@ -0,0 +1,31 @@
1
+ module ReleaseFeature
2
+ class Client
3
+ @environment: Symbol
4
+ @base_repository: Repository::YamlRepository | Repository::HashRepository | Repository::ActiveRecordRepository
5
+ @extra_repository: untyped | Repository::YamlRepository | Repository::HashRepository | Repository::ActiveRecordRepository
6
+ @refresh_interval_sec: Integer
7
+ @features: FeatureCollection
8
+
9
+ attr_reader updated_at: Time
10
+
11
+ def initialize: (environment: String | Symbol, base_repository: Repository::YamlRepository | Repository::HashRepository | Repository::ActiveRecordRepository, ?extra_repository: Repository::YamlRepository | Repository::HashRepository | Repository::ActiveRecordRepository, ?refresh_interval_sec: Integer) -> void
12
+
13
+ def permitted?: (Symbol feature_name, ?time: nil | Time) -> bool
14
+
15
+ def current_features: -> FeatureCollection
16
+
17
+ def refresh: -> bool
18
+
19
+ def names: -> Array[Symbol]
20
+
21
+ def environments: -> Array[Symbol]
22
+
23
+ private
24
+
25
+ def to_feature: (FeatureCollection features, Symbol name, Symbol environment) -> Feature
26
+
27
+ def expired?: (?Time? time) -> bool
28
+
29
+ def load_features: -> Time
30
+ end
31
+ end