flipper-firebase_remote_config 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 532ea32e91582a6ec5e68d328a951e98f5477137726cff2a177e53545f9b2d02
4
+ data.tar.gz: feaa5358f7005276dc1421370824fb55294319b85209e2c80a70e7816a8dbd79
5
+ SHA512:
6
+ metadata.gz: 2b08ecc82798840db761e5ccce7eab46e5adb0ccff42fde2329c0f44fb93c979811cc258225ab76289d2aac59755f1b009ea2fc4e2e7aae135f4653b51d4391c
7
+ data.tar.gz: 584edf4b6539b719974469fe9893982c2ae1563921979cc03d235aaeb6ead4b7a30a9395d814b63b6a26a21509e72329514fa02dfa7a1e2786386b7db3c917d6
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - Initial Flipper adapter targeting the Firebase Remote Config v1 REST API.
12
+ - One Remote Config parameter per feature (prefix configurable, default
13
+ `flipper__`), with an `__index__` sentinel parameter listing known feature
14
+ keys.
15
+ - In-process template + ETag cache with a configurable TTL (default 30s) and a
16
+ `#reload!` method to force-refresh.
17
+ - Optimistic-concurrency retry: one retry on HTTP 409/412 then re-raise.
18
+ - Service-account authentication via `googleauth`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DevOps Health
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # flipper-firebase_remote_config
2
+
3
+ A [Flipper](https://www.flippercloud.io/docs) adapter that stores feature state
4
+ in [Firebase Remote Config](https://firebase.google.com/docs/remote-config).
5
+ Useful when you want flags reachable from both your Ruby backend and your
6
+ Firebase-using mobile / web clients without standing up a separate flag store.
7
+
8
+ ## Read this first — when not to use it
9
+
10
+ Firebase Remote Config is **eventually consistent** and **rate-limited for
11
+ writes** (Firebase publishes a daily write quota per project, on the order of
12
+ hundreds of writes/day). This makes it a poor fit for:
13
+
14
+ - Per-request flag flipping
15
+ - High-frequency A/B test ramping
16
+ - Anything that depends on a write being visible immediately
17
+
18
+ It is a good fit for low-frequency operational toggles that you want a single
19
+ source of truth for across server and client platforms.
20
+
21
+ **Always wrap this adapter with [`Flipper::Adapters::Memoizable`](https://www.flippercloud.io/docs/adapters/memoizable)
22
+ or the per-request `Flipper::Middleware::Memoizer`** to avoid a Remote Config
23
+ fetch on every flag check.
24
+
25
+ ## Installation
26
+
27
+ ```ruby
28
+ # Gemfile
29
+ gem 'flipper-firebase_remote_config'
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ ```ruby
35
+ require 'flipper'
36
+ require 'flipper/adapters/firebase_remote_config'
37
+ require 'flipper/adapters/memoizable'
38
+
39
+ Flipper.configure do |config|
40
+ config.adapter do
41
+ base = Flipper::Adapters::FirebaseRemoteConfig.new(
42
+ project_id: ENV.fetch('FIREBASE_PROJECT_ID'),
43
+ credentials: ENV.fetch('GOOGLE_APPLICATION_CREDENTIALS'), # path to service-account JSON
44
+ prefix: 'flipper__', # optional, default 'flipper__'
45
+ cache_ttl: 30, # optional, default 30 seconds
46
+ )
47
+ Flipper::Adapters::Memoizable.new(base)
48
+ end
49
+ end
50
+ ```
51
+
52
+ The service account needs the **Firebase Remote Config Admin** role (or
53
+ equivalent custom role granting `cloudconfig.configs.get` and
54
+ `cloudconfig.configs.update`).
55
+
56
+ `credentials` accepts:
57
+
58
+ - A file path to a service-account JSON key (`String`)
59
+ - An open `IO`/`StringIO` containing service-account JSON
60
+ - A pre-built `Google::Auth::*` credentials object
61
+ - `nil` to fall back to Application Default Credentials
62
+
63
+ ## Storage layout
64
+
65
+ Each Flipper feature becomes one Remote Config parameter, named
66
+ `<prefix><feature_key>`. The parameter's `defaultValue.value` is a JSON blob
67
+ representing the gate state:
68
+
69
+ ```json
70
+ {
71
+ "boolean": "true",
72
+ "actors": ["1", "2"],
73
+ "groups": ["admins"],
74
+ "percentage_of_actors": "25",
75
+ "percentage_of_time": null
76
+ }
77
+ ```
78
+
79
+ A sentinel parameter `<prefix>__index__` holds a JSON array of all known
80
+ feature keys, so listing features doesn't have to scan every parameter.
81
+
82
+ ## Concurrency and retries
83
+
84
+ Remote Config uses ETag-based optimistic concurrency. The adapter:
85
+
86
+ 1. Fetches the template + ETag (cached for `cache_ttl` seconds).
87
+ 2. Mutates the template in memory.
88
+ 3. Publishes with `If-Match: <etag>`.
89
+ 4. On a `412 Precondition Failed`, reloads and retries **once**. If the retry
90
+ also fails, `Flipper::Adapters::FirebaseRemoteConfig::ETagMismatch` is
91
+ raised.
92
+
93
+ If you have a write-heavy multi-process workload that frequently conflicts,
94
+ this adapter is the wrong tool.
95
+
96
+ ## Not yet supported
97
+
98
+ - **Remote Config conditions.** Firebase Remote Config has a powerful
99
+ conditional value system (per-platform, per-country, per-user-property). v0.1
100
+ ignores it: every gate is stored as the parameter's `defaultValue` only. If
101
+ you change a parameter's `conditionalValues` in the Firebase console, the
102
+ adapter will not see those changes. Conditions may be exposed as a Flipper
103
+ extension in a future release; PRs welcome.
104
+ - **Server-side caching beyond the adapter's in-process TTL.** Combine with
105
+ `Flipper::Adapters::Memoizable` and ideally a longer-lived cache (Redis,
106
+ Memcached) for high-traffic apps.
107
+
108
+ ## Why this gem talks REST directly
109
+
110
+ The Firebase Remote Config v1 API does not have a generated Ruby client gem
111
+ (`google-apis-firebaseremoteconfig_v1` is not published to RubyGems, and the
112
+ deprecated umbrella `google-api-client` does not include it either). So the
113
+ two REST calls we actually need (`GET` + `PUT` on
114
+ `/v1/projects/{id}/remoteConfig`) go through `Net::HTTP` directly. Auth is
115
+ real: we depend on [`googleauth`](https://github.com/googleapis/google-auth-library-ruby)
116
+ for the OAuth2 service-account flow. See `CLAUDE.md` for the longer story.
117
+
118
+ ## Development
119
+
120
+ ```sh
121
+ bundle install
122
+ bundle exec rspec
123
+ bundle exec rubocop
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT.
@@ -0,0 +1,127 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'uri'
4
+ require 'googleauth'
5
+
6
+ module Flipper
7
+ module Adapters
8
+ class FirebaseRemoteConfig
9
+ class Error < StandardError; end
10
+ class ETagMismatch < Error; end
11
+
12
+ SCOPE = 'https://www.googleapis.com/auth/firebase.remoteconfig'.freeze
13
+ API_HOST = 'firebaseremoteconfig.googleapis.com'.freeze
14
+ OPEN_TIMEOUT = 5
15
+ READ_TIMEOUT = 15
16
+
17
+ # Thin REST wrapper around the Firebase Remote Config v1 API.
18
+ #
19
+ # Why hand-rolled instead of a generated client: there is no published
20
+ # service gem for `firebaseremoteconfig_v1` — neither bundled inside the
21
+ # (deprecated) `google-api-client` umbrella, nor as a stand-alone
22
+ # `google-apis-firebaseremoteconfig_v1`. We use `googleauth` directly
23
+ # for the OAuth2 service-account flow, and Net::HTTP for the two
24
+ # endpoints we actually need.
25
+ class Client
26
+ attr_reader :project_id
27
+
28
+ def initialize(project_id:, credentials: nil, http: nil)
29
+ @project_id = project_id
30
+ @credentials = build_credentials(credentials)
31
+ @http = http # injection seam for tests
32
+ end
33
+
34
+ # Returns [template_hash, etag_string]. The template is the parsed JSON
35
+ # body as a Hash; etag is the opaque string from the ETag response
36
+ # header, which the server demands back on the next write.
37
+ def fetch_template
38
+ response = request(:get, template_path)
39
+ ensure_success!(response)
40
+ [JSON.parse(response.body), response['ETag']]
41
+ end
42
+
43
+ # Publishes a modified template. Raises ETagMismatch on 409/412 so the
44
+ # adapter can reload and retry; raises Error on any other failure.
45
+ def publish_template(template, etag)
46
+ response = request(
47
+ :put,
48
+ template_path,
49
+ body: JSON.generate(template),
50
+ headers: { 'Content-Type' => 'application/json; UTF-8',
51
+ 'If-Match' => etag || '*' }
52
+ )
53
+ raise ETagMismatch, response.body if etag_conflict?(response)
54
+
55
+ ensure_success!(response)
56
+ response
57
+ end
58
+
59
+ private
60
+
61
+ def template_path
62
+ "/v1/projects/#{@project_id}/remoteConfig"
63
+ end
64
+
65
+ def request(method, path, body: nil, headers: {})
66
+ uri = URI("https://#{API_HOST}#{path}")
67
+ req_class = method == :get ? Net::HTTP::Get : Net::HTTP::Put
68
+ req = req_class.new(uri)
69
+ headers.each { |k, v| req[k] = v }
70
+ @credentials&.apply!(req.to_hash.merge('Authorization' => nil))
71
+ token = fetch_access_token
72
+ req['Authorization'] = "Bearer #{token}" if token
73
+ req.body = body if body
74
+
75
+ http_for(uri).request(req)
76
+ end
77
+
78
+ def http_for(uri)
79
+ return @http if @http
80
+
81
+ http = Net::HTTP.new(uri.host, uri.port)
82
+ http.use_ssl = true
83
+ http.open_timeout = OPEN_TIMEOUT
84
+ http.read_timeout = READ_TIMEOUT
85
+ http
86
+ end
87
+
88
+ def fetch_access_token
89
+ return nil unless @credentials
90
+
91
+ @credentials.fetch_access_token! unless @credentials.access_token
92
+ @credentials.access_token
93
+ end
94
+
95
+ def build_credentials(credentials)
96
+ case credentials
97
+ when String
98
+ ::Google::Auth::ServiceAccountCredentials.make_creds(
99
+ json_key_io: File.open(credentials),
100
+ scope: SCOPE
101
+ )
102
+ when IO, StringIO
103
+ ::Google::Auth::ServiceAccountCredentials.make_creds(
104
+ json_key_io: credentials,
105
+ scope: SCOPE
106
+ )
107
+ when nil
108
+ ::Google::Auth.get_application_default([SCOPE])
109
+ else
110
+ credentials
111
+ end
112
+ end
113
+
114
+ def etag_conflict?(response)
115
+ [409, 412].include?(response.code.to_i)
116
+ end
117
+
118
+ def ensure_success!(response)
119
+ return if (200..299).cover?(response.code.to_i)
120
+
121
+ raise Error,
122
+ "Firebase Remote Config API error #{response.code}: #{response.body}"
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,7 @@
1
+ module Flipper
2
+ module Adapters
3
+ class FirebaseRemoteConfig
4
+ VERSION = '0.0.1'.freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,266 @@
1
+ require 'json'
2
+ require 'set'
3
+ require 'flipper'
4
+ require 'flipper/adapters/firebase_remote_config/version'
5
+ require 'flipper/adapters/firebase_remote_config/client'
6
+
7
+ module Flipper
8
+ module Adapters
9
+ # Flipper adapter that stores feature state as Firebase Remote Config
10
+ # parameters. One parameter per feature; an index parameter tracks the
11
+ # known feature keys so listing doesn't have to scan the whole template.
12
+ #
13
+ # The in-memory representation of a template is the raw API JSON shape:
14
+ #
15
+ # {
16
+ # "parameters" => {
17
+ # "flipper__search" => {
18
+ # "valueType" => "JSON",
19
+ # "defaultValue" => { "value" => "{\"boolean\":\"true\",...}" }
20
+ # },
21
+ # "flipper____index__" => { ... JSON array of feature keys ... }
22
+ # },
23
+ # "conditions" => [...],
24
+ # ...
25
+ # }
26
+ #
27
+ # See README.md for the rationale and for caveats (eventual consistency,
28
+ # write quotas, no support for Remote Config conditions in v0.1).
29
+ class FirebaseRemoteConfig
30
+ DEFAULT_PREFIX = 'flipper__'.freeze
31
+ INDEX_SUFFIX = '__index__'.freeze
32
+ DEFAULT_CACHE_TTL = 30 # seconds
33
+
34
+ attr_reader :name
35
+
36
+ def initialize(project_id: nil, credentials: nil, client: nil,
37
+ prefix: DEFAULT_PREFIX, cache_ttl: DEFAULT_CACHE_TTL)
38
+ @name = :firebase_remote_config
39
+ @prefix = prefix
40
+ @cache_ttl = cache_ttl
41
+ @client = client || Client.new(project_id: project_id, credentials: credentials)
42
+ @cache = nil
43
+ @cached_at = nil
44
+ end
45
+
46
+ def features
47
+ Set.new(index_from(load_template))
48
+ end
49
+
50
+ def add(feature)
51
+ with_template do |template|
52
+ ensure_parameter(template, feature.key)
53
+ add_to_index(template, feature.key)
54
+ end
55
+ true
56
+ end
57
+
58
+ def remove(feature)
59
+ with_template do |template|
60
+ template['parameters']&.delete(parameter_name(feature.key))
61
+ remove_from_index(template, feature.key)
62
+ end
63
+ true
64
+ end
65
+
66
+ def clear(feature)
67
+ with_template do |template|
68
+ write_gates(template, feature.key, default_config)
69
+ end
70
+ true
71
+ end
72
+
73
+ def get(feature)
74
+ read_gates(load_template, feature.key)
75
+ end
76
+
77
+ def get_multi(features)
78
+ template = load_template
79
+ features.to_h do |feature|
80
+ [feature.key, read_gates(template, feature.key)]
81
+ end
82
+ end
83
+
84
+ def get_all
85
+ template = load_template
86
+ index_from(template).to_h do |feature_key|
87
+ [feature_key, read_gates(template, feature_key)]
88
+ end
89
+ end
90
+
91
+ def enable(feature, gate, thing)
92
+ mutate_gates(feature) { |gates| apply_enable_gate(gates, gate, thing) }
93
+ true
94
+ end
95
+
96
+ def disable(feature, gate, thing)
97
+ mutate_gates(feature) { |gates| apply_disable_gate(gates, gate, thing) }
98
+ true
99
+ end
100
+
101
+ # Drop the in-process cache. Call this when you know the template has
102
+ # drifted (e.g. another process published a new version).
103
+ def reload!
104
+ @cache = nil
105
+ @cached_at = nil
106
+ self
107
+ end
108
+
109
+ private
110
+
111
+ def mutate_gates(feature)
112
+ with_template do |template|
113
+ gates = yield(read_gates(template, feature.key))
114
+ write_gates(template, feature.key, gates)
115
+ add_to_index(template, feature.key)
116
+ end
117
+ end
118
+
119
+ def apply_enable_gate(gates, gate, thing)
120
+ case gate.data_type
121
+ when :boolean
122
+ default_config.merge(gate.key => thing.value.to_s)
123
+ when :integer
124
+ gates.merge(gate.key => thing.value.to_s)
125
+ when :set
126
+ gates.merge(gate.key => (gates[gate.key] || Set.new) | [thing.value.to_s])
127
+ when :json
128
+ gates.merge(gate.key => thing.value)
129
+ else
130
+ raise ArgumentError, "Unsupported gate data_type: #{gate.data_type.inspect}"
131
+ end
132
+ end
133
+
134
+ def apply_disable_gate(gates, gate, thing)
135
+ case gate.data_type
136
+ when :boolean
137
+ default_config
138
+ when :integer
139
+ gates.merge(gate.key => thing.value.to_s)
140
+ when :set
141
+ gates.merge(gate.key => (gates[gate.key] || Set.new) - [thing.value.to_s])
142
+ when :json
143
+ gates.merge(gate.key => nil)
144
+ else
145
+ raise ArgumentError, "Unsupported gate data_type: #{gate.data_type.inspect}"
146
+ end
147
+ end
148
+
149
+ def default_config
150
+ {
151
+ boolean: nil,
152
+ actors: Set.new,
153
+ groups: Set.new,
154
+ percentage_of_actors: nil,
155
+ percentage_of_time: nil
156
+ }
157
+ end
158
+
159
+ def load_template
160
+ return @cache[:template] if @cache && @cached_at && (Time.now - @cached_at) < @cache_ttl
161
+
162
+ template, etag = @client.fetch_template
163
+ @cache = { template: template, etag: etag }
164
+ @cached_at = Time.now
165
+ template
166
+ end
167
+
168
+ # Mutate the cached template in place inside the block, then publish it.
169
+ # Retries once on ETag mismatch by reloading and reapplying the block.
170
+ def with_template
171
+ attempts = 0
172
+ begin
173
+ template = load_template
174
+ etag = @cache[:etag]
175
+ yield template
176
+ @client.publish_template(template, etag)
177
+ reload!
178
+ rescue FirebaseRemoteConfig::ETagMismatch
179
+ attempts += 1
180
+ if attempts <= 1
181
+ reload!
182
+ retry
183
+ end
184
+ raise
185
+ end
186
+ end
187
+
188
+ def parameter_name(feature_key)
189
+ "#{@prefix}#{feature_key}"
190
+ end
191
+
192
+ def index_parameter_name
193
+ "#{@prefix}#{INDEX_SUFFIX}"
194
+ end
195
+
196
+ def index_from(template)
197
+ raw = parameter_value(template, index_parameter_name)
198
+ return [] if raw.nil?
199
+
200
+ JSON.parse(raw)
201
+ rescue JSON::ParserError
202
+ []
203
+ end
204
+
205
+ def add_to_index(template, feature_key)
206
+ keys = (index_from(template) + [feature_key]).uniq.sort
207
+ write_parameter(template, index_parameter_name, JSON.generate(keys))
208
+ end
209
+
210
+ def remove_from_index(template, feature_key)
211
+ keys = index_from(template) - [feature_key]
212
+ write_parameter(template, index_parameter_name, JSON.generate(keys))
213
+ end
214
+
215
+ def ensure_parameter(template, feature_key)
216
+ return if (template['parameters'] || {}).key?(parameter_name(feature_key))
217
+
218
+ write_gates(template, feature_key, default_config)
219
+ end
220
+
221
+ def read_gates(template, feature_key)
222
+ raw_json = parameter_value(template, parameter_name(feature_key))
223
+ return default_config if raw_json.nil?
224
+
225
+ raw = JSON.parse(raw_json, symbolize_names: true)
226
+ deserialize_gates(raw)
227
+ rescue JSON::ParserError
228
+ default_config
229
+ end
230
+
231
+ def write_gates(template, feature_key, gates)
232
+ write_parameter(template, parameter_name(feature_key),
233
+ JSON.generate(serialize_gates(gates)))
234
+ end
235
+
236
+ def parameter_value(template, name)
237
+ param = (template['parameters'] || {})[name]
238
+ param&.dig('defaultValue', 'value')
239
+ end
240
+
241
+ def write_parameter(template, name, json_value)
242
+ template['parameters'] ||= {}
243
+ template['parameters'][name] = {
244
+ 'valueType' => 'JSON',
245
+ 'defaultValue' => { 'value' => json_value }
246
+ }
247
+ end
248
+
249
+ def serialize_gates(gates)
250
+ gates.each_with_object({}) do |(key, value), acc|
251
+ acc[key] = value.is_a?(Set) ? value.to_a : value
252
+ end
253
+ end
254
+
255
+ def deserialize_gates(raw)
256
+ default_config.merge(
257
+ boolean: raw[:boolean],
258
+ actors: Set.new(Array(raw[:actors])),
259
+ groups: Set.new(Array(raw[:groups])),
260
+ percentage_of_actors: raw[:percentage_of_actors],
261
+ percentage_of_time: raw[:percentage_of_time]
262
+ )
263
+ end
264
+ end
265
+ end
266
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flipper-firebase_remote_config
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Roberto Quintanilla
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: flipper
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '2.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '1.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '2.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: googleauth
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
46
+ description: Stores Flipper features as Firebase Remote Config parameters, reading
47
+ and writing via the Firebase Remote Config REST API.
48
+ email:
49
+ - roberto.quintanilla@gmail.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - CHANGELOG.md
55
+ - LICENSE
56
+ - README.md
57
+ - lib/flipper/adapters/firebase_remote_config.rb
58
+ - lib/flipper/adapters/firebase_remote_config/client.rb
59
+ - lib/flipper/adapters/firebase_remote_config/version.rb
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ rubygems_mfa_required: 'true'
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '2.7'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 4.0.10
79
+ specification_version: 4
80
+ summary: Flipper adapter backed by Firebase Remote Config.
81
+ test_files: []