remocon 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.gitignore +313 -0
- data/.rspec +3 -0
- data/.rubocop.yml +70 -0
- data/.ruby-version +1 -0
- data/.travis.yml +13 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +72 -0
- data/LICENSE.txt +21 -0
- data/README.md +91 -0
- data/Rakefile +6 -0
- data/bin/console +15 -0
- data/bin/get_access_token +11 -0
- data/bin/get_access_token.py +18 -0
- data/bin/put_by_curl +13 -0
- data/bin/setup +8 -0
- data/cmd/remocon +6 -0
- data/lib/remocon.rb +47 -0
- data/lib/remocon/cli.rb +41 -0
- data/lib/remocon/command/create_command.rb +48 -0
- data/lib/remocon/command/lib/interpreter_helper.rb +44 -0
- data/lib/remocon/command/pull_command.rb +66 -0
- data/lib/remocon/command/push_command.rb +119 -0
- data/lib/remocon/command/validate_command.rb +38 -0
- data/lib/remocon/dumper/condition_file_dumper.rb +14 -0
- data/lib/remocon/dumper/parameter_file_dumper.rb +22 -0
- data/lib/remocon/error/unsupported_type_error.rb +9 -0
- data/lib/remocon/error/validation_error.rb +10 -0
- data/lib/remocon/interpreter/condition_file_interpreter.rb +32 -0
- data/lib/remocon/interpreter/parameter_file_interpreter.rb +60 -0
- data/lib/remocon/normalizer/boolean_normalizer.rb +23 -0
- data/lib/remocon/normalizer/integer_normalizer.rb +23 -0
- data/lib/remocon/normalizer/json_normalizer.rb +20 -0
- data/lib/remocon/normalizer/normalizer.rb +33 -0
- data/lib/remocon/normalizer/string_normalizer.rb +13 -0
- data/lib/remocon/normalizer/type_normalizer_factory.rb +25 -0
- data/lib/remocon/normalizer/void_normalizer.rb +9 -0
- data/lib/remocon/sorter/condition_sorter.rb +23 -0
- data/lib/remocon/sorter/parameter_sorter.rb +24 -0
- data/lib/remocon/type.rb +11 -0
- data/lib/remocon/util/array.rb +15 -0
- data/lib/remocon/util/hash.rb +29 -0
- data/lib/remocon/util/string.rb +13 -0
- data/lib/remocon/version.rb +5 -0
- data/remocon.gemspec +42 -0
- data/sample/basketball-b8548/conditions.yml +7 -0
- data/sample/basketball-b8548/config.json +67 -0
- data/sample/basketball-b8548/etag +1 -0
- data/sample/basketball-b8548/parameters.yml +20 -0
- 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,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,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,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
|