flipper-cloud 0.19.1 → 0.20.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6216e75f04e5bef06c69e891f6ed5efe6e3fd76031fd696cc286282a41976049
4
- data.tar.gz: 508fda309071c22ffe75372ebbd6c419a18083c4e996c392029d631a9f6968b5
3
+ metadata.gz: 35dc166068996854a78fc08810a942999539735d61f8f0215d6c10101a57d23d
4
+ data.tar.gz: bdccf48ff9d058fc20ded13e1c64d7f64964c2daad12fe0ccc11b8aafcd2d0ab
5
5
  SHA512:
6
- metadata.gz: 46f2f8c832d94884c7faa1093ddf2910d47ebb9d755ee677e9599760a28bae56f09cb091be0df7d2dc6830863c23536341f0ac30005a6505b1240eac44d2c930
7
- data.tar.gz: f4ad93d8c151754b60eda5952a156347e3864eb0d5be5e2ba2cea25940adb8680403b8ce5a99d362d73553f42f50acbd545a9d3342cb75918f3c516dc8b6e95d
6
+ metadata.gz: a2f6190b1ff9a30ce518a0044323c55a9997db8cf3ec383c4d425ce5eb02121e99a355ca370928df1b9a23ea5add521133fb30a051b56a93b8ce088707d1f2a3
7
+ data.tar.gz: 132a9a42b22803fa362b85acfd1c741519ce7eff91d5443bf47b8d0d869cd29d6a161ded0e15ef80fa7a642d46b948a454a0c633a72ff5cbbc22949dd2459622
@@ -0,0 +1,17 @@
1
+ # Usage (from the repo root):
2
+ # env FLIPPER_CLOUD_TOKEN=<token> FLIPPER_CLOUD_SYNC_SECRET=<secret> FLIPPER_CLOUD_SYNC_METHOD=webhook bundle exec rackup examples/ui/basic.ru -p 9999
3
+ # env FLIPPER_CLOUD_TOKEN=<token> FLIPPER_CLOUD_SYNC_SECRET=<secret> FLIPPER_CLOUD_SYNC_METHOD=webhook bundle exec shotgun examples/ui/basic.ru -p 9999
4
+ # http://localhost:9999/
5
+ # http://localhost:9999/webhooks
6
+
7
+ require 'pathname'
8
+ root_path = Pathname(__FILE__).dirname.join('..').expand_path
9
+ lib_path = root_path.join('lib')
10
+ $:.unshift(lib_path)
11
+
12
+ require 'flipper/cloud'
13
+ Flipper.configure do |config|
14
+ config.default { Flipper::Cloud.new }
15
+ end
16
+
17
+ run Flipper::Cloud.app
@@ -1,5 +1,9 @@
1
1
  require "flipper"
2
+ require "flipper/middleware/setup_env"
3
+ require "flipper/middleware/memoizer"
2
4
  require "flipper/cloud/configuration"
5
+ require "flipper/cloud/dsl"
6
+ require "flipper/cloud/middleware"
3
7
 
4
8
  module Flipper
5
9
  module Cloud
@@ -10,10 +14,27 @@ module Flipper
10
14
  # options - The Hash of options. See Flipper::Cloud::Configuration.
11
15
  # block - The block that configuration will be yielded to allowing you to
12
16
  # customize this cloud instance and its adapter.
13
- def self.new(token, options = {})
14
- configuration = Configuration.new(options.merge(token: token))
17
+ def self.new(token = nil, options = {})
18
+ options = options.merge(token: token) if token
19
+ configuration = Configuration.new(options)
15
20
  yield configuration if block_given?
16
- Flipper.new(configuration.adapter, instrumenter: configuration.instrumenter)
21
+ DSL.new(configuration)
22
+ end
23
+
24
+ def self.app(flipper = nil, options = {})
25
+ env_key = options.fetch(:env_key, 'flipper')
26
+ memoizer_options = options.fetch(:memoizer_options, {})
27
+
28
+ app = ->(_) { [404, { 'Content-Type'.freeze => 'application/json'.freeze }, ['{}'.freeze]] }
29
+ builder = Rack::Builder.new
30
+ yield builder if block_given?
31
+ builder.use Flipper::Middleware::SetupEnv, flipper, env_key: env_key
32
+ builder.use Flipper::Middleware::Memoizer, memoizer_options.merge(env_key: env_key)
33
+ builder.use Flipper::Cloud::Middleware, env_key: env_key
34
+ builder.run app
35
+ klass = self
36
+ builder.define_singleton_method(:inspect) { klass.inspect } # pretty rake routes output
37
+ builder
17
38
  end
18
39
  end
19
40
  end
@@ -1,19 +1,22 @@
1
1
  require "flipper/adapters/http"
2
2
  require "flipper/adapters/memory"
3
+ require "flipper/adapters/dual_write"
3
4
  require "flipper/adapters/sync"
4
5
 
5
6
  module Flipper
6
7
  module Cloud
7
8
  class Configuration
8
- # The default url should be the one, the only, the website.
9
- DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze
9
+ # The set of valid ways that syncing can happpen.
10
+ VALID_SYNC_METHODS = Set[
11
+ :poll,
12
+ :webhook,
13
+ ].freeze
10
14
 
11
15
  # Public: The token corresponding to an environment on flippercloud.io.
12
16
  attr_accessor :token
13
17
 
14
- # Public: The url for http adapter (default: Flipper::Cloud::DEFAULT_URL).
15
- # Really should only be customized for development work. Feel free
16
- # to forget you ever saw this.
18
+ # Public: The url for http adapter. Really should only be customized for
19
+ # development work. Feel free to forget you ever saw this.
17
20
  attr_reader :url
18
21
 
19
22
  # Public: net/http read timeout for all http requests (default: 5).
@@ -53,18 +56,32 @@ module Flipper
53
56
  # the local in sync with cloud (default: 10).
54
57
  attr_accessor :sync_interval
55
58
 
59
+ # Public: The method to be used for synchronizing your local flipper
60
+ # adapter with cloud. (default: :poll, can also be :webhook).
61
+ attr_reader :sync_method
62
+
63
+ # Public: The secret used to verify if syncs in the middleware should
64
+ # occur or not.
65
+ attr_accessor :sync_secret
66
+
56
67
  def initialize(options = {})
57
- @token = options.fetch(:token)
68
+ @token = options.fetch(:token) { ENV["FLIPPER_CLOUD_TOKEN"] }
69
+
70
+ if @token.nil?
71
+ raise ArgumentError, "Flipper::Cloud token is missing. Please set FLIPPER_CLOUD_TOKEN or provide the token (e.g. Flipper::Cloud.new('token'))."
72
+ end
73
+
58
74
  @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
59
- @read_timeout = options.fetch(:read_timeout, 5)
60
- @open_timeout = options.fetch(:open_timeout, 5)
61
- @write_timeout = options.fetch(:write_timeout, 5)
62
- @sync_interval = options.fetch(:sync_interval, 10)
75
+ @read_timeout = options.fetch(:read_timeout) { ENV.fetch("FLIPPER_CLOUD_READ_TIMEOUT", 5).to_f }
76
+ @open_timeout = options.fetch(:open_timeout) { ENV.fetch("FLIPPER_CLOUD_OPEN_TIMEOUT", 5).to_f }
77
+ @write_timeout = options.fetch(:write_timeout) { ENV.fetch("FLIPPER_CLOUD_WRITE_TIMEOUT", 5).to_f }
78
+ @sync_interval = options.fetch(:sync_interval) { ENV.fetch("FLIPPER_CLOUD_SYNC_INTERVAL", 10).to_f }
79
+ @sync_secret = options.fetch(:sync_secret) { ENV["FLIPPER_CLOUD_SYNC_SECRET"] }
63
80
  @local_adapter = options.fetch(:local_adapter) { Adapters::Memory.new }
64
81
  @debug_output = options[:debug_output]
65
82
  @adapter_block = ->(adapter) { adapter }
66
-
67
- self.url = options.fetch(:url, DEFAULT_URL)
83
+ self.sync_method = options.fetch(:sync_method) { ENV.fetch("FLIPPER_CLOUD_SYNC_METHOD", :poll).to_sym }
84
+ self.url = options.fetch(:url) { ENV.fetch("FLIPPER_CLOUD_URL", "https://www.flippercloud.io/adapter".freeze) }
68
85
  end
69
86
 
70
87
  # Public: Read or customize the http adapter. Calling without a block will
@@ -82,34 +99,62 @@ module Flipper
82
99
  if block_given?
83
100
  @adapter_block = block
84
101
  else
85
- @adapter_block.call sync_adapter
102
+ @adapter_block.call app_adapter
86
103
  end
87
104
  end
88
105
 
89
106
  # Public: Set url for the http adapter.
90
107
  attr_writer :url
91
108
 
109
+ def sync
110
+ Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
111
+ instrumenter: instrumenter,
112
+ interval: sync_interval,
113
+ }).call
114
+ end
115
+
116
+ def sync_method=(new_sync_method)
117
+ new_sync_method = new_sync_method.to_sym
118
+
119
+ unless VALID_SYNC_METHODS.include?(new_sync_method)
120
+ raise ArgumentError, "Unsupported sync_method. Valid options are (#{VALID_SYNC_METHODS.to_a.join(', ')})"
121
+ end
122
+
123
+ if new_sync_method == :webhook && sync_secret.nil?
124
+ raise ArgumentError, "Flipper::Cloud sync_secret is missing. Please set FLIPPER_CLOUD_SYNC_SECRET or provide the sync_secret used to validate webhooks."
125
+ end
126
+
127
+ @sync_method = new_sync_method
128
+ end
129
+
92
130
  private
93
131
 
132
+ def app_adapter
133
+ sync_method == :webhook ? dual_write_adapter : sync_adapter
134
+ end
135
+
136
+ def dual_write_adapter
137
+ Flipper::Adapters::DualWrite.new(local_adapter, http_adapter)
138
+ end
139
+
94
140
  def sync_adapter
95
- sync_options = {
141
+ Flipper::Adapters::Sync.new(local_adapter, http_adapter, {
96
142
  instrumenter: instrumenter,
97
143
  interval: sync_interval,
98
- }
99
- Flipper::Adapters::Sync.new(local_adapter, http_adapter, sync_options)
144
+ })
100
145
  end
101
146
 
102
147
  def http_adapter
103
- http_options = {
148
+ Flipper::Adapters::Http.new({
104
149
  url: @url,
105
150
  read_timeout: @read_timeout,
106
151
  open_timeout: @open_timeout,
107
152
  debug_output: @debug_output,
108
153
  headers: {
154
+ "Flipper-Cloud-Token" => @token,
109
155
  "Feature-Flipper-Token" => @token,
110
156
  },
111
- }
112
- Flipper::Adapters::Http.new(http_options)
157
+ })
113
158
  end
114
159
  end
115
160
  end
@@ -0,0 +1,27 @@
1
+ require 'forwardable'
2
+
3
+ module Flipper
4
+ module Cloud
5
+ class DSL < SimpleDelegator
6
+ attr_reader :cloud_configuration
7
+
8
+ def initialize(cloud_configuration)
9
+ @cloud_configuration = cloud_configuration
10
+ super Flipper.new(@cloud_configuration.adapter, instrumenter: @cloud_configuration.instrumenter)
11
+ end
12
+
13
+ def sync
14
+ @cloud_configuration.sync
15
+ end
16
+
17
+ def sync_secret
18
+ @cloud_configuration.sync_secret
19
+ end
20
+
21
+ def inspect
22
+ inspect_id = ::Kernel::format "%x", (object_id * 2)
23
+ %(#<#{self.class}:0x#{inspect_id} @cloud_configuration=#{cloud_configuration.inspect}, flipper=#{__getobj__.inspect}>)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,95 @@
1
+ require "openssl"
2
+ require "digest/sha2"
3
+
4
+ module Flipper
5
+ module Cloud
6
+ class MessageVerifier
7
+ class InvalidSignature < StandardError; end
8
+
9
+ DEFAULT_VERSION = "v1"
10
+
11
+ def self.header(signature, timestamp, version = DEFAULT_VERSION)
12
+ raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time)
13
+ raise ArgumentError, "signature should be a string" unless signature.is_a?(String)
14
+ "t=#{timestamp.to_i},#{version}=#{signature}"
15
+ end
16
+
17
+ def initialize(secret:, version: DEFAULT_VERSION)
18
+ @secret = secret
19
+ @version = version || DEFAULT_VERSION
20
+
21
+ raise ArgumentError, "secret should be a string" unless @secret.is_a?(String)
22
+ raise ArgumentError, "version should be a string" unless @version.is_a?(String)
23
+ end
24
+
25
+ def generate(payload, timestamp)
26
+ raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time)
27
+ raise ArgumentError, "payload should be a string" unless payload.is_a?(String)
28
+
29
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), @secret, "#{timestamp.to_i}.#{payload}")
30
+ end
31
+
32
+ def header(signature, timestamp)
33
+ self.class.header(signature, timestamp, @version)
34
+ end
35
+
36
+ # Public: Verifies the signature header for a given payload.
37
+ #
38
+ # Raises a InvalidSignature in the following cases:
39
+ # - the header does not match the expected format
40
+ # - no signatures found with the expected scheme
41
+ # - no signatures matching the expected signature
42
+ # - a tolerance is provided and the timestamp is not within the
43
+ # tolerance
44
+ #
45
+ # Returns true otherwise.
46
+ def verify(payload, header, tolerance: nil)
47
+ begin
48
+ timestamp, signatures = get_timestamp_and_signatures(header)
49
+ rescue StandardError
50
+ raise InvalidSignature, "Unable to extract timestamp and signatures from header"
51
+ end
52
+
53
+ if signatures.empty?
54
+ raise InvalidSignature, "No signatures found with expected version #{@version}"
55
+ end
56
+
57
+ expected_sig = generate(payload, timestamp)
58
+ unless signatures.any? { |s| secure_compare(expected_sig, s) }
59
+ raise InvalidSignature, "No signatures found matching the expected signature for payload"
60
+ end
61
+
62
+ if tolerance && timestamp < Time.now - tolerance
63
+ raise InvalidSignature, "Timestamp outside the tolerance zone (#{Time.at(timestamp)})"
64
+ end
65
+
66
+ true
67
+ end
68
+
69
+ private
70
+
71
+ # Extracts the timestamp and the signature(s) with the desired version
72
+ # from the header
73
+ def get_timestamp_and_signatures(header)
74
+ list_items = header.split(/,\s*/).map { |i| i.split("=", 2) }
75
+ timestamp = Integer(list_items.select { |i| i[0] == "t" }[0][1])
76
+ signatures = list_items.select { |i| i[0] == @version }.map { |i| i[1] }
77
+ [Time.at(timestamp), signatures]
78
+ end
79
+
80
+ # Private
81
+ def fixed_length_secure_compare(a, b)
82
+ raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize
83
+ l = a.unpack "C#{a.bytesize}"
84
+ res = 0
85
+ b.each_byte { |byte| res |= byte ^ l.shift }
86
+ res == 0
87
+ end
88
+
89
+ # Private
90
+ def secure_compare(a, b)
91
+ fixed_length_secure_compare(::Digest::SHA256.digest(a), ::Digest::SHA256.digest(b)) && a == b
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flipper/cloud/message_verifier"
4
+
5
+ module Flipper
6
+ module Cloud
7
+ class Middleware
8
+ # Internal: The path to match for webhook requests.
9
+ WEBHOOK_PATH = %r{\A/webhooks\/?\Z}
10
+
11
+ def initialize(app, options = {})
12
+ @app = app
13
+ @env_key = options.fetch(:env_key, 'flipper')
14
+ end
15
+
16
+ def call(env)
17
+ dup.call!(env)
18
+ end
19
+
20
+ def call!(env)
21
+ request = Rack::Request.new(env)
22
+ if request.post? && request.path_info.match(WEBHOOK_PATH)
23
+ status = 200
24
+ headers = {
25
+ "Content-Type" => "application/json",
26
+ }
27
+ body = "{}"
28
+ payload = request.body.read
29
+ signature = request.env["HTTP_FLIPPER_CLOUD_SIGNATURE"]
30
+ flipper = env.fetch(@env_key)
31
+
32
+ begin
33
+ message_verifier = MessageVerifier.new(secret: flipper.sync_secret)
34
+ if message_verifier.verify(payload, signature)
35
+ begin
36
+ flipper.sync
37
+ body = JSON.generate({
38
+ groups: Flipper.group_names.map { |name| {name: name}}
39
+ })
40
+ rescue Flipper::Adapters::Http::Error => error
41
+ status = error.response.code.to_i == 402 ? 402 : 500
42
+ headers["Flipper-Cloud-Response-Error-Class"] = error.class.name
43
+ headers["Flipper-Cloud-Response-Error-Message"] = error.message
44
+ rescue => error
45
+ status = 500
46
+ headers["Flipper-Cloud-Response-Error-Class"] = error.class.name
47
+ headers["Flipper-Cloud-Response-Error-Message"] = error.message
48
+ end
49
+ end
50
+ rescue MessageVerifier::InvalidSignature
51
+ status = 400
52
+ end
53
+
54
+ [status, headers, [body]]
55
+ else
56
+ @app.call(env)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,3 +1,3 @@
1
1
  module Flipper
2
- VERSION = '0.19.1'.freeze
2
+ VERSION = '0.20.1'.freeze
3
3
  end
@@ -12,6 +12,13 @@ RSpec.describe Flipper::Cloud::Configuration do
12
12
  expect(instance.token).to eq(required_options[:token])
13
13
  end
14
14
 
15
+ it "can set token from ENV var" do
16
+ with_modified_env "FLIPPER_CLOUD_TOKEN" => "from_env" do
17
+ instance = described_class.new(required_options.reject { |k, v| k == :token })
18
+ expect(instance.token).to eq("from_env")
19
+ end
20
+ end
21
+
15
22
  it "can set instrumenter" do
16
23
  instrumenter = Object.new
17
24
  instance = described_class.new(required_options.merge(instrumenter: instrumenter))
@@ -23,21 +30,49 @@ RSpec.describe Flipper::Cloud::Configuration do
23
30
  expect(instance.read_timeout).to eq(5)
24
31
  end
25
32
 
33
+ it "can set read_timeout from ENV var" do
34
+ with_modified_env "FLIPPER_CLOUD_READ_TIMEOUT" => "9" do
35
+ instance = described_class.new(required_options.reject { |k, v| k == :read_timeout })
36
+ expect(instance.read_timeout).to eq(9)
37
+ end
38
+ end
39
+
26
40
  it "can set open_timeout" do
27
41
  instance = described_class.new(required_options.merge(open_timeout: 5))
28
42
  expect(instance.open_timeout).to eq(5)
29
43
  end
30
44
 
45
+ it "can set open_timeout from ENV var" do
46
+ with_modified_env "FLIPPER_CLOUD_OPEN_TIMEOUT" => "9" do
47
+ instance = described_class.new(required_options.reject { |k, v| k == :open_timeout })
48
+ expect(instance.open_timeout).to eq(9)
49
+ end
50
+ end
51
+
31
52
  it "can set write_timeout" do
32
53
  instance = described_class.new(required_options.merge(write_timeout: 5))
33
54
  expect(instance.write_timeout).to eq(5)
34
55
  end
35
56
 
57
+ it "can set write_timeout from ENV var" do
58
+ with_modified_env "FLIPPER_CLOUD_WRITE_TIMEOUT" => "9" do
59
+ instance = described_class.new(required_options.reject { |k, v| k == :write_timeout })
60
+ expect(instance.write_timeout).to eq(9)
61
+ end
62
+ end
63
+
36
64
  it "can set sync_interval" do
37
65
  instance = described_class.new(required_options.merge(sync_interval: 1))
38
66
  expect(instance.sync_interval).to eq(1)
39
67
  end
40
68
 
69
+ it "can set sync_interval from ENV var" do
70
+ with_modified_env "FLIPPER_CLOUD_SYNC_INTERVAL" => "5" do
71
+ instance = described_class.new(required_options.reject { |k, v| k == :sync_interval })
72
+ expect(instance.sync_interval).to eq(5)
73
+ end
74
+ end
75
+
41
76
  it "passes sync_interval into sync adapter" do
42
77
  # The initial sync of http to local invokes this web request.
43
78
  stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
@@ -70,7 +105,12 @@ RSpec.describe Flipper::Cloud::Configuration do
70
105
  expect(instance.adapter).to be_instance_of(Flipper::Adapters::Instrumented)
71
106
  end
72
107
 
73
- it "can override url" do
108
+ it "defaults url" do
109
+ instance = described_class.new(required_options.reject { |k, v| k == :url })
110
+ expect(instance.url).to eq("https://www.flippercloud.io/adapter")
111
+ end
112
+
113
+ it "can override url using options" do
74
114
  options = required_options.merge(url: "http://localhost:5000/adapter")
75
115
  instance = described_class.new(options)
76
116
  expect(instance.url).to eq("http://localhost:5000/adapter")
@@ -79,4 +119,157 @@ RSpec.describe Flipper::Cloud::Configuration do
79
119
  instance.url = "http://localhost:5000/adapter"
80
120
  expect(instance.url).to eq("http://localhost:5000/adapter")
81
121
  end
122
+
123
+ it "can override URL using ENV var" do
124
+ with_modified_env "FLIPPER_CLOUD_URL" => "https://example.com" do
125
+ instance = described_class.new(required_options.reject { |k, v| k == :url })
126
+ expect(instance.url).to eq("https://example.com")
127
+ end
128
+ end
129
+
130
+ it "defaults to sync_method to poll" do
131
+ memory_adapter = Flipper::Adapters::Memory.new
132
+ instance = described_class.new(required_options)
133
+
134
+ expect(instance.sync_method).to eq(:poll)
135
+ end
136
+
137
+ it "can use webhook for sync_method" do
138
+ memory_adapter = Flipper::Adapters::Memory.new
139
+ instance = described_class.new(required_options.merge({
140
+ sync_secret: "secret",
141
+ sync_method: :webhook,
142
+ local_adapter: memory_adapter,
143
+ }))
144
+
145
+ expect(instance.sync_method).to eq(:webhook)
146
+ expect(instance.adapter).to be_instance_of(Flipper::Adapters::DualWrite)
147
+ end
148
+
149
+ it "raises ArgumentError for invalid sync_method" do
150
+ expect {
151
+ described_class.new(required_options.merge(sync_method: :foo))
152
+ }.to raise_error(ArgumentError, "Unsupported sync_method. Valid options are (poll, webhook)")
153
+ end
154
+
155
+ it "can use ENV var for sync_method" do
156
+ with_modified_env "FLIPPER_CLOUD_SYNC_METHOD" => "webhook" do
157
+ instance = described_class.new(required_options.merge({
158
+ sync_secret: "secret",
159
+ }))
160
+
161
+ expect(instance.sync_method).to eq(:webhook)
162
+ end
163
+ end
164
+
165
+ it "can use string sync_method instead of symbol" do
166
+ memory_adapter = Flipper::Adapters::Memory.new
167
+ instance = described_class.new(required_options.merge({
168
+ sync_secret: "secret",
169
+ sync_method: "webhook",
170
+ local_adapter: memory_adapter,
171
+ }))
172
+
173
+ expect(instance.sync_method).to eq(:webhook)
174
+ expect(instance.adapter).to be_instance_of(Flipper::Adapters::DualWrite)
175
+ end
176
+
177
+ it "can set sync_secret" do
178
+ instance = described_class.new(required_options.merge(sync_secret: "from_config"))
179
+ expect(instance.sync_secret).to eq("from_config")
180
+ end
181
+
182
+ it "can override sync_secret using ENV var" do
183
+ with_modified_env "FLIPPER_CLOUD_SYNC_SECRET" => "from_env" do
184
+ instance = described_class.new(required_options.reject { |k, v| k == :sync_secret })
185
+ expect(instance.sync_secret).to eq("from_env")
186
+ end
187
+ end
188
+
189
+ it "can sync with cloud" do
190
+ body = JSON.generate({
191
+ "features": [
192
+ {
193
+ "key": "search",
194
+ "state": "on",
195
+ "gates": [
196
+ {
197
+ "key": "boolean",
198
+ "name": "boolean",
199
+ "value": true
200
+ },
201
+ {
202
+ "key": "groups",
203
+ "name": "group",
204
+ "value": []
205
+ },
206
+ {
207
+ "key": "actors",
208
+ "name": "actor",
209
+ "value": []
210
+ },
211
+ {
212
+ "key": "percentage_of_actors",
213
+ "name": "percentage_of_actors",
214
+ "value": 0
215
+ },
216
+ {
217
+ "key": "percentage_of_time",
218
+ "name": "percentage_of_time",
219
+ "value": 0
220
+ }
221
+ ]
222
+ },
223
+ {
224
+ "key": "history",
225
+ "state": "off",
226
+ "gates": [
227
+ {
228
+ "key": "boolean",
229
+ "name": "boolean",
230
+ "value": false
231
+ },
232
+ {
233
+ "key": "groups",
234
+ "name": "group",
235
+ "value": []
236
+ },
237
+ {
238
+ "key": "actors",
239
+ "name": "actor",
240
+ "value": []
241
+ },
242
+ {
243
+ "key": "percentage_of_actors",
244
+ "name": "percentage_of_actors",
245
+ "value": 0
246
+ },
247
+ {
248
+ "key": "percentage_of_time",
249
+ "name": "percentage_of_time",
250
+ "value": 0
251
+ }
252
+ ]
253
+ }
254
+ ]
255
+ })
256
+ stub = stub_request(:get, "https://www.flippercloud.io/adapter/features").
257
+ with({
258
+ headers: {
259
+ 'Flipper-Cloud-Token'=>'asdf',
260
+ },
261
+ }).to_return(status: 200, body: body, headers: {})
262
+ instance = described_class.new(required_options)
263
+ instance.sync
264
+
265
+ # Check that remote was fetched.
266
+ expect(stub).to have_been_requested
267
+
268
+ # Check that local adapter really did sync.
269
+ local_adapter = instance.adapter.instance_variable_get("@local")
270
+ all = local_adapter.get_all
271
+ expect(all.keys).to eq(["search", "history"])
272
+ expect(all["search"][:boolean]).to eq("true")
273
+ expect(all["history"][:boolean]).to eq(nil)
274
+ end
82
275
  end
@@ -0,0 +1,87 @@
1
+ require 'helper'
2
+ require 'flipper/cloud/configuration'
3
+ require 'flipper/cloud/dsl'
4
+ require 'flipper/adapters/operation_logger'
5
+ require 'flipper/adapters/instrumented'
6
+
7
+ RSpec.describe Flipper::Cloud::DSL do
8
+ it 'delegates everything to flipper instance' do
9
+ cloud_configuration = Flipper::Cloud::Configuration.new({
10
+ token: "asdf",
11
+ sync_secret: "tasty",
12
+ sync_method: :webhook,
13
+ })
14
+ dsl = described_class.new(cloud_configuration)
15
+ expect(dsl.features).to eq(Set.new)
16
+ expect(dsl.enabled?(:foo)).to be(false)
17
+ end
18
+
19
+ it 'delegates sync to cloud configuration' do
20
+ stub = stub_request(:get, "https://www.flippercloud.io/adapter/features").
21
+ with({
22
+ headers: {
23
+ 'Flipper-Cloud-Token'=>'asdf',
24
+ },
25
+ }).to_return(status: 200, body: '{"features": {}}', headers: {})
26
+ cloud_configuration = Flipper::Cloud::Configuration.new({
27
+ token: "asdf",
28
+ sync_secret: "tasty",
29
+ sync_method: :webhook,
30
+ })
31
+ dsl = described_class.new(cloud_configuration)
32
+ dsl.sync
33
+ expect(stub).to have_been_requested
34
+ end
35
+
36
+ it 'delegates sync_secret to cloud configuration' do
37
+ cloud_configuration = Flipper::Cloud::Configuration.new({
38
+ token: "asdf",
39
+ sync_secret: "tasty",
40
+ sync_method: :webhook,
41
+ })
42
+ dsl = described_class.new(cloud_configuration)
43
+ expect(dsl.sync_secret).to eq("tasty")
44
+ end
45
+
46
+ context "when sync_method is webhook" do
47
+ let(:local_adapter) do
48
+ Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new
49
+ end
50
+
51
+ let(:cloud_configuration) do
52
+ cloud_configuration = Flipper::Cloud::Configuration.new({
53
+ token: "asdf",
54
+ sync_secret: "tasty",
55
+ sync_method: :webhook,
56
+ local_adapter: local_adapter
57
+ })
58
+ end
59
+
60
+ subject do
61
+ described_class.new(cloud_configuration)
62
+ end
63
+
64
+ it "sends reads to local adapter" do
65
+ subject.features
66
+ subject.enabled?(:foo)
67
+ expect(local_adapter.count(:features)).to be(1)
68
+ expect(local_adapter.count(:get)).to be(1)
69
+ end
70
+
71
+ it "sends writes to cloud and local" do
72
+ add_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features").
73
+ with({headers: {'Flipper-Cloud-Token'=>'asdf'}}).
74
+ to_return(status: 200, body: '{}', headers: {})
75
+ enable_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features/foo/boolean").
76
+ with(headers: {'Flipper-Cloud-Token'=>'asdf'}).
77
+ to_return(status: 200, body: '{}', headers: {})
78
+
79
+ subject.enable(:foo)
80
+
81
+ expect(local_adapter.count(:add)).to be(1)
82
+ expect(local_adapter.count(:enable)).to be(1)
83
+ expect(add_stub).to have_been_requested
84
+ expect(enable_stub).to have_been_requested
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,105 @@
1
+ require 'helper'
2
+ require 'flipper/cloud/message_verifier'
3
+
4
+ RSpec.describe Flipper::Cloud::MessageVerifier do
5
+ let(:payload) { "some payload" }
6
+ let(:secret) { "secret" }
7
+ let(:timestamp) { Time.now }
8
+
9
+ describe "#generate" do
10
+ it "generates signature that can be verified" do
11
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
12
+ signature = message_verifier.generate(payload, timestamp)
13
+ header = generate_header(timestamp: timestamp, signature: signature)
14
+ expect(message_verifier.verify(payload, header)).to be(true)
15
+ end
16
+ end
17
+
18
+ describe "#header" do
19
+ it "generates a header in valid format" do
20
+ version = "v1"
21
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
22
+ signature = message_verifier.generate(payload, timestamp)
23
+ header = message_verifier.header(signature, timestamp)
24
+ expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
25
+ end
26
+ end
27
+
28
+ describe ".header" do
29
+ it "generates a header in valid format" do
30
+ version = "v1"
31
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
32
+ signature = message_verifier.generate(payload, timestamp)
33
+
34
+ header = Flipper::Cloud::MessageVerifier.header(signature, timestamp, version)
35
+ expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
36
+ end
37
+ end
38
+
39
+ describe "#verify" do
40
+ it "raises a InvalidSignature when the header does not have the expected format" do
41
+ header = "i'm not even a real signature header"
42
+ expect {
43
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
44
+ message_verifier.verify(payload, header)
45
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "Unable to extract timestamp and signatures from header")
46
+ end
47
+
48
+ it "raises a InvalidSignature when there are no signatures with the expected version" do
49
+ header = generate_header(version: "v0")
50
+ expect {
51
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
52
+ message_verifier.verify(payload, header)
53
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /No signatures found with expected version/)
54
+ end
55
+
56
+ it "raises a InvalidSignature when there are no valid signatures for the payload" do
57
+ header = generate_header(signature: "bad_signature")
58
+ expect {
59
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
60
+ message_verifier.verify(payload, header)
61
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "No signatures found matching the expected signature for payload")
62
+ end
63
+
64
+ it "raises a InvalidSignature when the timestamp is not within the tolerance" do
65
+ header = generate_header(timestamp: Time.now - 15)
66
+ expect {
67
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
68
+ message_verifier.verify(payload, header, tolerance: 10)
69
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /Timestamp outside the tolerance zone/)
70
+ end
71
+
72
+ it "returns true when the header contains a valid signature and the timestamp is within the tolerance" do
73
+ header = generate_header
74
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
75
+ expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
76
+ end
77
+
78
+ it "returns true when the header contains at least one valid signature" do
79
+ header = generate_header + ",v1=bad_signature"
80
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
81
+ expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
82
+ end
83
+
84
+ it "returns true when the header contains a valid signature and the timestamp is off but no tolerance is provided" do
85
+ header = generate_header(timestamp: Time.at(12_345))
86
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
87
+ expect(message_verifier.verify(payload, header)).to be(true)
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def generate_header(options = {})
94
+ options[:secret] ||= secret
95
+ options[:version] ||= "v1"
96
+
97
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: options[:secret], version: options[:version])
98
+
99
+ options[:timestamp] ||= timestamp
100
+ options[:payload] ||= payload
101
+ options[:signature] ||= message_verifier.generate(options[:payload], options[:timestamp])
102
+
103
+ Flipper::Cloud::MessageVerifier.header(options[:signature], options[:timestamp], options[:version])
104
+ end
105
+ end
@@ -0,0 +1,262 @@
1
+ require 'securerandom'
2
+ require 'helper'
3
+ require 'flipper/cloud'
4
+ require 'flipper/cloud/middleware'
5
+ require 'flipper/adapters/operation_logger'
6
+
7
+ RSpec.describe Flipper::Cloud::Middleware do
8
+ let(:flipper) {
9
+ Flipper::Cloud.new("regular") do |config|
10
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
11
+ config.sync_secret = "regular_tasty"
12
+ config.sync_method = :webhook
13
+ end
14
+ }
15
+
16
+ let(:env_flipper) {
17
+ Flipper::Cloud.new("env") do |config|
18
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
19
+ config.sync_secret = "env_tasty"
20
+ config.sync_method = :webhook
21
+ end
22
+ }
23
+
24
+ let(:app) { Flipper::Cloud.app(flipper) }
25
+ let(:response_body) { JSON.generate({features: {}}) }
26
+ let(:request_body) {
27
+ JSON.generate({
28
+ "environment_id" => 1,
29
+ "webhook_id" => 1,
30
+ "delivery_id" => SecureRandom.uuid,
31
+ "action" => "sync",
32
+ })
33
+ }
34
+ let(:timestamp) { Time.now }
35
+ let(:signature) {
36
+ Flipper::Cloud::MessageVerifier.new(secret: flipper.sync_secret).generate(request_body, timestamp)
37
+ }
38
+ let(:signature_header_value) {
39
+ Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
40
+ }
41
+
42
+ context 'when initializing middleware with flipper instance' do
43
+ let(:app) { Flipper::Cloud.app(flipper) }
44
+
45
+ it 'uses instance to sync' do
46
+ Flipper.register(:admins) { |*args| false }
47
+ Flipper.register(:staff) { |*args| false }
48
+ Flipper.register(:basic) { |*args| false }
49
+ Flipper.register(:plus) { |*args| false }
50
+ Flipper.register(:premium) { |*args| false }
51
+
52
+ stub = stub_request_for_token('regular')
53
+ env = {
54
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
55
+ }
56
+ post '/webhooks', request_body, env
57
+
58
+ expect(last_response.status).to eq(200)
59
+ expect(JSON.parse(last_response.body)).to eq({
60
+ "groups" => [
61
+ {"name" => "admins"},
62
+ {"name" => "staff"},
63
+ {"name" => "basic"},
64
+ {"name" => "plus"},
65
+ {"name" => "premium"},
66
+ ],
67
+ })
68
+ expect(stub).to have_been_requested
69
+ end
70
+ end
71
+
72
+ context 'when signature is invalid' do
73
+ let(:app) { Flipper::Cloud.app(flipper) }
74
+ let(:signature) {
75
+ Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
76
+ }
77
+
78
+ it 'uses instance to sync' do
79
+ stub = stub_request_for_token('regular')
80
+ env = {
81
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
82
+ }
83
+ post '/webhooks', request_body, env
84
+
85
+ expect(last_response.status).to eq(400)
86
+ expect(stub).not_to have_been_requested
87
+ end
88
+ end
89
+
90
+ context "when flipper cloud responds with 402" do
91
+ let(:app) { Flipper::Cloud.app(flipper) }
92
+
93
+ it "results in 402" do
94
+ Flipper.register(:admins) { |*args| false }
95
+ Flipper.register(:staff) { |*args| false }
96
+ Flipper.register(:basic) { |*args| false }
97
+ Flipper.register(:plus) { |*args| false }
98
+ Flipper.register(:premium) { |*args| false }
99
+
100
+ stub = stub_request_for_token('regular', status: 402)
101
+ env = {
102
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
103
+ }
104
+ post '/webhooks', request_body, env
105
+
106
+ expect(last_response.status).to eq(402)
107
+ expect(last_response.headers["Flipper-Cloud-Response-Error-Class"]).to eq("Flipper::Adapters::Http::Error")
108
+ expect(last_response.headers["Flipper-Cloud-Response-Error-Message"]).to eq("Failed with status: 402")
109
+ expect(stub).to have_been_requested
110
+ end
111
+ end
112
+
113
+ context "when flipper cloud responds with non-402 and non-2xx code" do
114
+ let(:app) { Flipper::Cloud.app(flipper) }
115
+
116
+ it "results in 500" do
117
+ Flipper.register(:admins) { |*args| false }
118
+ Flipper.register(:staff) { |*args| false }
119
+ Flipper.register(:basic) { |*args| false }
120
+ Flipper.register(:plus) { |*args| false }
121
+ Flipper.register(:premium) { |*args| false }
122
+
123
+ stub = stub_request_for_token('regular', status: 503)
124
+ env = {
125
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
126
+ }
127
+ post '/webhooks', request_body, env
128
+
129
+ expect(last_response.status).to eq(500)
130
+ expect(last_response.headers["Flipper-Cloud-Response-Error-Class"]).to eq("Flipper::Adapters::Http::Error")
131
+ expect(last_response.headers["Flipper-Cloud-Response-Error-Message"]).to eq("Failed with status: 503")
132
+ expect(stub).to have_been_requested
133
+ end
134
+ end
135
+
136
+ context "when flipper cloud responds with timeout" do
137
+ let(:app) { Flipper::Cloud.app(flipper) }
138
+
139
+ it "results in 500" do
140
+ Flipper.register(:admins) { |*args| false }
141
+ Flipper.register(:staff) { |*args| false }
142
+ Flipper.register(:basic) { |*args| false }
143
+ Flipper.register(:plus) { |*args| false }
144
+ Flipper.register(:premium) { |*args| false }
145
+
146
+ stub = stub_request_for_token('regular', status: :timeout)
147
+ env = {
148
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
149
+ }
150
+ post '/webhooks', request_body, env
151
+
152
+ expect(last_response.status).to eq(500)
153
+ expect(last_response.headers["Flipper-Cloud-Response-Error-Class"]).to eq("Net::OpenTimeout")
154
+ expect(last_response.headers["Flipper-Cloud-Response-Error-Message"]).to eq("execution expired")
155
+ expect(stub).to have_been_requested
156
+ end
157
+ end
158
+
159
+ context 'when initialized with flipper instance and flipper instance in env' do
160
+ let(:app) { Flipper::Cloud.app(flipper) }
161
+ let(:signature) {
162
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
163
+ }
164
+
165
+ it 'uses env instance to sync' do
166
+ stub = stub_request_for_token('env')
167
+ env = {
168
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
169
+ 'flipper' => env_flipper,
170
+ }
171
+ post '/webhooks', request_body, env
172
+
173
+ expect(last_response.status).to eq(200)
174
+ expect(stub).to have_been_requested
175
+ end
176
+ end
177
+
178
+ context 'when initialized without flipper instance but flipper instance in env' do
179
+ let(:app) { Flipper::Cloud.app }
180
+ let(:signature) {
181
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
182
+ }
183
+
184
+ it 'uses env instance to sync' do
185
+ stub = stub_request_for_token('env')
186
+ env = {
187
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
188
+ 'flipper' => env_flipper,
189
+ }
190
+ post '/webhooks', request_body, env
191
+
192
+ expect(last_response.status).to eq(200)
193
+ expect(stub).to have_been_requested
194
+ end
195
+ end
196
+
197
+ context 'when initialized with env_key' do
198
+ let(:app) { Flipper::Cloud.app(flipper, env_key: 'flipper_cloud') }
199
+ let(:signature) {
200
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
201
+ }
202
+
203
+ it 'uses provided env key instead of default' do
204
+ stub = stub_request_for_token('env')
205
+ env = {
206
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
207
+ 'flipper' => flipper,
208
+ 'flipper_cloud' => env_flipper,
209
+ }
210
+ post '/webhooks', request_body, env
211
+
212
+ expect(last_response.status).to eq(200)
213
+ expect(stub).to have_been_requested
214
+ end
215
+ end
216
+
217
+ context 'when initializing lazily with a block' do
218
+ let(:app) { Flipper::Cloud.app(-> { flipper }) }
219
+
220
+ it 'works' do
221
+ stub = stub_request_for_token('regular')
222
+ env = {
223
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
224
+ }
225
+ post '/webhooks', request_body, env
226
+
227
+ expect(last_response.status).to eq(200)
228
+ expect(stub).to have_been_requested
229
+ end
230
+ end
231
+
232
+ describe 'Request method unsupported' do
233
+ it 'skips middleware' do
234
+ get '/webhooks'
235
+ expect(last_response.status).to eq(404)
236
+ expect(last_response.content_type).to eq("application/json")
237
+ expect(last_response.body).to eq("{}")
238
+ end
239
+ end
240
+
241
+ describe 'Inspecting the built Rack app' do
242
+ it 'returns a String' do
243
+ expect(Flipper::Cloud.app(flipper).inspect).to be_a(String)
244
+ end
245
+ end
246
+
247
+ private
248
+
249
+ def stub_request_for_token(token, status: 200)
250
+ stub = stub_request(:get, "https://www.flippercloud.io/adapter/features").
251
+ with({
252
+ headers: {
253
+ 'Flipper-Cloud-Token' => token,
254
+ },
255
+ })
256
+ if status == :timeout
257
+ stub.to_timeout
258
+ else
259
+ stub.to_return(status: status, body: response_body, headers: {})
260
+ end
261
+ end
262
+ end
@@ -20,7 +20,11 @@ RSpec.describe Flipper::Cloud do
20
20
  end
21
21
 
22
22
  it 'returns Flipper::DSL instance' do
23
- expect(@instance).to be_instance_of(Flipper::DSL)
23
+ expect(@instance).to be_instance_of(Flipper::Cloud::DSL)
24
+ end
25
+
26
+ it 'can read the cloud configuration' do
27
+ expect(@instance.cloud_configuration).to be_instance_of(Flipper::Cloud::Configuration)
24
28
  end
25
29
 
26
30
  it 'configures instance to use http adapter' do
@@ -36,7 +40,7 @@ RSpec.describe Flipper::Cloud do
36
40
 
37
41
  it 'sets correct token header' do
38
42
  headers = @http_client.instance_variable_get('@headers')
39
- expect(headers['Feature-Flipper-Token']).to eq(token)
43
+ expect(headers['Flipper-Cloud-Token']).to eq(token)
40
44
  end
41
45
 
42
46
  it 'uses noop instrumenter' do
@@ -63,6 +67,12 @@ RSpec.describe Flipper::Cloud do
63
67
  end
64
68
  end
65
69
 
70
+ it 'can initialize with no token explicitly provided' do
71
+ with_modified_env "FLIPPER_CLOUD_TOKEN" => "asdf" do
72
+ expect(described_class.new).to be_instance_of(Flipper::Cloud::DSL)
73
+ end
74
+ end
75
+
66
76
  it 'can set instrumenter' do
67
77
  instrumenter = Flipper::Instrumenters::Memory.new
68
78
  instance = described_class.new('asdf', instrumenter: instrumenter)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flipper-cloud
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.20.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Nunemaker
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-12-04 00:00:00.000000000 Z
11
+ date: 2020-12-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: flipper
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.19.1
19
+ version: 0.20.1
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.19.1
26
+ version: 0.20.1
27
27
  description:
28
28
  email:
29
29
  - nunemaker@gmail.com
@@ -31,6 +31,7 @@ executables: []
31
31
  extensions: []
32
32
  extra_rdoc_files: []
33
33
  files:
34
+ - examples/cloud/app.ru
34
35
  - examples/cloud/basic.rb
35
36
  - examples/cloud/cached_in_memory.rb
36
37
  - examples/cloud/import.rb
@@ -39,8 +40,14 @@ files:
39
40
  - lib/flipper-cloud.rb
40
41
  - lib/flipper/cloud.rb
41
42
  - lib/flipper/cloud/configuration.rb
43
+ - lib/flipper/cloud/dsl.rb
44
+ - lib/flipper/cloud/message_verifier.rb
45
+ - lib/flipper/cloud/middleware.rb
42
46
  - lib/flipper/version.rb
43
47
  - spec/flipper/cloud/configuration_spec.rb
48
+ - spec/flipper/cloud/dsl_spec.rb
49
+ - spec/flipper/cloud/message_verifier_spec.rb
50
+ - spec/flipper/cloud/middleware_spec.rb
44
51
  - spec/flipper/cloud_spec.rb
45
52
  homepage: https://github.com/jnunemaker/flipper
46
53
  licenses:
@@ -68,4 +75,7 @@ specification_version: 4
68
75
  summary: FeatureFlipper.com adapter for Flipper
69
76
  test_files:
70
77
  - spec/flipper/cloud/configuration_spec.rb
78
+ - spec/flipper/cloud/dsl_spec.rb
79
+ - spec/flipper/cloud/message_verifier_spec.rb
80
+ - spec/flipper/cloud/middleware_spec.rb
71
81
  - spec/flipper/cloud_spec.rb