release_feature 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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