remocon 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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