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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +128 -0
- data/lib/flipper/adapters/firebase_remote_config/client.rb +127 -0
- data/lib/flipper/adapters/firebase_remote_config/version.rb +7 -0
- data/lib/flipper/adapters/firebase_remote_config.rb +266 -0
- metadata +81 -0
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,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: []
|