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.
- 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
|