remocon 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.gitignore +313 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +70 -0
  6. data/.ruby-version +1 -0
  7. data/.travis.yml +13 -0
  8. data/Gemfile +7 -0
  9. data/Gemfile.lock +72 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +91 -0
  12. data/Rakefile +6 -0
  13. data/bin/console +15 -0
  14. data/bin/get_access_token +11 -0
  15. data/bin/get_access_token.py +18 -0
  16. data/bin/put_by_curl +13 -0
  17. data/bin/setup +8 -0
  18. data/cmd/remocon +6 -0
  19. data/lib/remocon.rb +47 -0
  20. data/lib/remocon/cli.rb +41 -0
  21. data/lib/remocon/command/create_command.rb +48 -0
  22. data/lib/remocon/command/lib/interpreter_helper.rb +44 -0
  23. data/lib/remocon/command/pull_command.rb +66 -0
  24. data/lib/remocon/command/push_command.rb +119 -0
  25. data/lib/remocon/command/validate_command.rb +38 -0
  26. data/lib/remocon/dumper/condition_file_dumper.rb +14 -0
  27. data/lib/remocon/dumper/parameter_file_dumper.rb +22 -0
  28. data/lib/remocon/error/unsupported_type_error.rb +9 -0
  29. data/lib/remocon/error/validation_error.rb +10 -0
  30. data/lib/remocon/interpreter/condition_file_interpreter.rb +32 -0
  31. data/lib/remocon/interpreter/parameter_file_interpreter.rb +60 -0
  32. data/lib/remocon/normalizer/boolean_normalizer.rb +23 -0
  33. data/lib/remocon/normalizer/integer_normalizer.rb +23 -0
  34. data/lib/remocon/normalizer/json_normalizer.rb +20 -0
  35. data/lib/remocon/normalizer/normalizer.rb +33 -0
  36. data/lib/remocon/normalizer/string_normalizer.rb +13 -0
  37. data/lib/remocon/normalizer/type_normalizer_factory.rb +25 -0
  38. data/lib/remocon/normalizer/void_normalizer.rb +9 -0
  39. data/lib/remocon/sorter/condition_sorter.rb +23 -0
  40. data/lib/remocon/sorter/parameter_sorter.rb +24 -0
  41. data/lib/remocon/type.rb +11 -0
  42. data/lib/remocon/util/array.rb +15 -0
  43. data/lib/remocon/util/hash.rb +29 -0
  44. data/lib/remocon/util/string.rb +13 -0
  45. data/lib/remocon/version.rb +5 -0
  46. data/remocon.gemspec +42 -0
  47. data/sample/basketball-b8548/conditions.yml +7 -0
  48. data/sample/basketball-b8548/config.json +67 -0
  49. data/sample/basketball-b8548/etag +1 -0
  50. data/sample/basketball-b8548/parameters.yml +20 -0
  51. metadata +193 -0
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: false
2
+
3
+ module Remocon
4
+ module Command
5
+ class Push
6
+ attr_reader :uri
7
+
8
+ def initialize(opts)
9
+ @opts = opts
10
+
11
+ @project_id = ENV.fetch('FIREBASE_PROJECT_ID')
12
+ @token = ENV.fetch('REMOTE_CONFIG_ACCESS_TOKEN')
13
+ @uri = URI.parse("https://firebaseremoteconfig.googleapis.com/v1/projects/#{@project_id}/remoteConfig")
14
+ @source_filepath = @opts[:source]
15
+ @etag = File.exist?(@opts[:etag]) ? File.open(@opts[:etag]).read : @opts[:etag] if @opts[:etag]
16
+ @ignore_etag = @opts[:force]
17
+ @dest_dir = File.join(@opts[:dest], @project_id) if @opts[:dest]
18
+
19
+ @cmd_opts = { validate_only: false }
20
+ end
21
+
22
+ def run
23
+ # to prevent a real request in spec
24
+ do_request
25
+ end
26
+
27
+ def client
28
+ return @client if @client
29
+
30
+ client = Net::HTTP.new(uri.host, uri.port)
31
+ client.use_ssl = true
32
+
33
+ @client = client
34
+ end
35
+
36
+ def request
37
+ return @request if @request
38
+
39
+ raise 'etag should be specified. If you want to ignore this error, then add --force option' unless @etag || @ignore_etag
40
+
41
+ headers = {
42
+ 'Authorization' => "Bearer #{@token}",
43
+ 'Content-Type' => 'application/json; UTF8'
44
+ }
45
+ headers['If-Match'] = @etag || '*'
46
+
47
+ request = Net::HTTP::Put.new(uri.request_uri, headers)
48
+ request.body = ""
49
+ request.body << File.read(@source_filepath).delete("\r\n")
50
+
51
+ @request = request
52
+ end
53
+
54
+ private
55
+
56
+ def do_request
57
+ response = client.request(request)
58
+
59
+ response_body = begin
60
+ json_str = response&.read_body
61
+ JSON.parse(json_str).with_indifferent_access if json_str
62
+ end
63
+
64
+ case response
65
+ when Net::HTTPOK
66
+ parse_success_body(response, response_body)
67
+ # intentional behavior
68
+ STDERR.puts 'Updated successfully.'
69
+ when Net::HTTPBadRequest
70
+ # sent json contains errors
71
+ parse_error_body(response, response_body) if response_body
72
+ STDERR.puts '400 but no error body' unless response_body
73
+ when Net::HTTPUnauthorized
74
+ # token was expired
75
+ STDERR.puts '401 Unauthorized. A token might be expired or invalid.'
76
+ when Net::HTTPForbidden
77
+ # remote config api might be disabled or not yet activated
78
+ STDERR.puts '403 Forbidden. RemoteConfig API might not be activated or be disabled.'
79
+ when Net::HTTPConflict
80
+ # local content is out-to-date
81
+ STDERR.puts '409 Conflict. Remote was updated. Please update your local files'
82
+ end
83
+ end
84
+
85
+ def parse_success_body(response, _success_body)
86
+ return unless etag = response.header["etag"]
87
+
88
+ if @dest_dir
89
+ File.open(File.join(@dest_dir, 'etag'), 'w+') do |f|
90
+ f.write(etag)
91
+ f.flush
92
+ end
93
+ else
94
+ STDOUT.puts etag
95
+ end
96
+ end
97
+
98
+ def parse_error_body(_response, error_body)
99
+ STDERR.puts "Error name : #{error_body[:error][:status]}"
100
+ STDERR.puts "Please check your json file"
101
+
102
+ error_body.dig(:error, :details)&.each do |k|
103
+ # for now, see only errors below
104
+ next unless k['@type'] == 'type.googleapis.com/google.rpc.BadRequest'
105
+
106
+ k[:fieldViolations].each do |e|
107
+ if e[:field].start_with?('remote_config.conditions')
108
+ STDERR.puts 'CONDITION DEFINITION ERROR'
109
+ else
110
+ STDERR.puts 'PARAMETER DEFINITION ERROR'
111
+ end
112
+
113
+ STDERR.puts e[:description]
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ module Command
5
+ class Validate
6
+ include Remocon::InterpreterHelper
7
+
8
+ def initialize(opts)
9
+ @opts = opts
10
+
11
+ @conditions_filepath = @opts[:conditions]
12
+ @parameters_filepath = @opts[:parameters]
13
+
14
+ @cmd_opts = { validate_only: true }
15
+ end
16
+
17
+ def run
18
+ validate_options
19
+
20
+ if parameter_errors.empty? && condition_errors.empty?
21
+ STDOUT.puts 'No error was found.'
22
+ else
23
+ (parameter_errors + condition_errors).each do |e|
24
+ STDERR.puts "#{e.class} #{e.message}"
25
+ STDERR.puts e.backtrace.join("\n")
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def validate_options
33
+ raise ValidationError, 'A condition file must exist' unless File.exist?(@conditions_filepath)
34
+ raise ValidationError, 'A parameter file must exist' unless File.exist?(@parameters_filepath)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class ConditionFileDumper
5
+ def initialize(conditions)
6
+ @conditions = conditions
7
+ end
8
+
9
+ def dump
10
+ # use as it is
11
+ @conditions
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class ParameterFileDumper
5
+ def initialize(parameters)
6
+ @parameters = parameters.with_indifferent_access
7
+ end
8
+
9
+ def dump
10
+ @parameters.each_with_object({}) do |(key, body), hash|
11
+ hash[key] = body[:defaultValue]
12
+ hash[key][:description] = body[:description] if body[:description]
13
+
14
+ next unless body[:conditionalValues]
15
+
16
+ hash[key][:conditions] = body[:conditionalValues].each_with_object({}) do |(key2, body2), hash2|
17
+ hash2[key2] = body2
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class UnsupportedTypeError < StandardError
5
+ def initialize(type)
6
+ super "#{type} is not supported as type"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class ValidationError < StandardError; end
5
+
6
+ class EmptyNameError < ValidationError; end
7
+ class EmptyExpressionError < ValidationError; end
8
+ class DuplicateKeyError < ValidationError; end
9
+ class NotFoundConditionKey < ValidationError; end
10
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class ConditionFileInterpreter
5
+ SUPPORTED_ATTRIBUTED = %i[name expression tagColor]
6
+
7
+ def initialize(filepath)
8
+ # conditions should be Array
9
+ @yaml = YAML.safe_load(File.open(filepath).read).map(&:with_indifferent_access)
10
+ end
11
+
12
+ def read(opts = {})
13
+ errors = []
14
+ json_array = @yaml.dup
15
+
16
+ keys = []
17
+
18
+ @yaml.each do |hash|
19
+ raise Remocon::EmptyNameError, 'name must not be empty' unless hash[:name]
20
+ raise Remocon::EmptyExpressionError, 'expression must not be empty' unless hash[:expression]
21
+ raise Remocon::DuplicateKeyError, "#{hash[:name]} is duplicated" if keys.include?(hash[:name])
22
+
23
+ keys.push(hash[:name])
24
+ rescue Remocon::ValidationError => e
25
+ raise e unless opts[:validate_only]
26
+ errors.push(e)
27
+ end
28
+
29
+ [json_array, errors]
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class ParameterFileInterpreter
5
+ def initialize(filepath)
6
+ @yaml = YAML.safe_load(File.open(filepath).read).with_indifferent_access
7
+ end
8
+
9
+ def read(condition_names, opts = {})
10
+ errors = []
11
+ json_hash = @yaml.each_with_object({}) do |(key, body), hash|
12
+ raise Remocon::DuplicateKeyError, "#{key} is duplicated" if hash[key]
13
+
14
+ hash[key] = {
15
+ defaultValue: {
16
+ value: parse_value_body(key, body)
17
+ }
18
+ }
19
+
20
+ hash[key][:conditionalValues] = parse_condition_body(condition_names, key, body[:conditions]) if body[:conditions]
21
+ hash[key][:description] = body[:description] if body[:description]
22
+ rescue Remocon::ValidationError => e
23
+ raise e unless opts[:validate_only]
24
+ errors.push(e)
25
+ end
26
+
27
+ [json_hash.with_indifferent_access, errors]
28
+ end
29
+
30
+ private
31
+
32
+ def read_value(body)
33
+ body[:file] ? File.open(body[:file]).read : body[:value]
34
+ end
35
+
36
+ def parse_value_body(key, value_body)
37
+ case value_body
38
+ when Hash
39
+ value = read_value(value_body)
40
+ options = { key: key }.merge(value_body[:options] || {})
41
+ normalizer = TypeNormalizerFactory.get(value_body[:normalizer]).new(value, options)
42
+ normalizer.process
43
+ normalizer.content
44
+ else # includes Array
45
+ # use raw value
46
+ value_body
47
+ end
48
+ end
49
+
50
+ def parse_condition_body(condition_names, key, condition_body)
51
+ condition_body.each_with_object({}) do |(cond_key, body), hash|
52
+ raise Remocon::NotFoundConditionKey, "The condition '#{cond_key}' is not defined" unless condition_names.include?(cond_key.to_s)
53
+
54
+ hash[cond_key] = {
55
+ value: parse_value_body(key, body)
56
+ }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class BooleanNormalizer < Remocon::Normalizer
5
+ def self.respond_symbol
6
+ Remocon::Type::BOOLEAN
7
+ end
8
+
9
+ def validate
10
+ return if [FalseClass, TrueClass].include?(@content.class)
11
+
12
+ begin
13
+ @bool_val = @content.to_s.to_boolean
14
+ rescue ArgumentError => e
15
+ raise ValidationError, e.message
16
+ end
17
+ end
18
+
19
+ def normalize
20
+ @bool_val
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class IntegerNormalizer < Remocon::Normalizer
5
+ def self.respond_symbol
6
+ Remocon::Type::INTEGER
7
+ end
8
+
9
+ def validate
10
+ return if @content.class == Integer.class
11
+
12
+ begin
13
+ @int_val = @content.to_s.to_integer
14
+ rescue ArgumentError => e
15
+ raise ValidationError, e.message
16
+ end
17
+ end
18
+
19
+ def normalize
20
+ @int_val
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class JsonNormalizer < Remocon::Normalizer
5
+ def self.respond_symbol
6
+ Remocon::Type::JSON
7
+ end
8
+
9
+ def validate
10
+ str_content = @content.is_a?(Hash) ? @content.to_json : @content.to_s
11
+ @json = JSON.parse(str_content).to_json
12
+ rescue JSON::ParserError => e
13
+ raise ValidationError, e.message
14
+ end
15
+
16
+ def normalize
17
+ @json
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class Normalizer
5
+ attr_reader :content
6
+
7
+ def initialize(content, opts)
8
+ @content = content.nil? ? opts[:default] : content
9
+ @opts = opts
10
+ end
11
+
12
+ def process
13
+ tap do
14
+ raise Remocon::ValidationError, "#{self.class} is not satisfying normalizer" unless self.class.respond_symbol
15
+
16
+ validate
17
+ @content = normalize
18
+ end
19
+ end
20
+
21
+ def validate
22
+ # no-op
23
+ end
24
+
25
+ def normalize
26
+ @content
27
+ end
28
+
29
+ def self.respond_symbol
30
+ raise Remocon::UnsupportedTypeError, 'unknown'
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class StringNormalizer < Remocon::Normalizer
5
+ def self.respond_symbol
6
+ Remocon::Type::STRING
7
+ end
8
+
9
+ def normalize
10
+ @content&.to_s
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remocon
4
+ class TypeNormalizerFactory
5
+ include Singleton
6
+
7
+ def self.register(normalizer_klass)
8
+ raise "#{normalizer_klass} must inherit #{Remocon::Normalizer}" unless normalizer_klass < Remocon::Normalizer
9
+ instance.plugin_map[normalizer_klass.respond_symbol] = normalizer_klass
10
+ end
11
+
12
+ def self.get(normalizer_sym)
13
+ instance.plugin_map[normalizer_sym&.to_sym || Remocon::Type::VOID]
14
+ end
15
+
16
+ def plugin_map
17
+ return @plugin_map if @plugin_map
18
+ @plugin_map = {}
19
+
20
+ Remocon::Normalizer.subclasses.each { |klass| Remocon::TypeNormalizerFactory.register(klass) }
21
+
22
+ @plugin_map
23
+ end
24
+ end
25
+ end