flippant 0.6.0 → 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.rubocop.yml +14 -11
- data/.tool-versions +1 -0
- data/.travis.yml +8 -2
- data/CHANGELOG.md +29 -3
- data/README.md +162 -17
- data/benchmarks/adapters.rb +43 -0
- data/flippant.gemspec +14 -13
- data/lib/flippant/adapters/memory.rb +43 -31
- data/lib/flippant/adapters/postgres.rb +175 -0
- data/lib/flippant/adapters/redis.rb +55 -45
- data/lib/flippant/rules.rb +13 -0
- data/lib/flippant/version.rb +1 -1
- data/lib/flippant.rb +46 -7
- metadata +56 -11
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: eb2f65a02966b9232561570498e893b0905ca3685b6e826b79376d7ede5c76d5
|
4
|
+
data.tar.gz: e6c8c1487f5ef80b0f467305076c36aca091fbcd38d1953fe4e03de6ea8dcb87
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: afc9ead23ec2566f9750802e73c6895a8ac99aa75b7f6bd9295b7c48d0aecd5b3d839eacf83ed30637c09a8f1c2cff34c0e82623a3edd3d2b0d7892de0093bc8
|
7
|
+
data.tar.gz: dcb7abfe21f01a19d900c3933ec43d5b62a6a2be34f6c054b6ed6abc0ed9f8aa82567306730e52500fb86fba05b5d18e0ae83754cf433c96d8110c63b47748c3
|
data/.rubocop.yml
CHANGED
@@ -4,12 +4,26 @@ AllCops:
|
|
4
4
|
- "vendor/**/*"
|
5
5
|
TargetRubyVersion: 2.3
|
6
6
|
|
7
|
+
Layout/IndentArray:
|
8
|
+
Enabled: false
|
9
|
+
|
10
|
+
Layout/SpaceInsideHashLiteralBraces:
|
11
|
+
EnforcedStyle: no_space
|
12
|
+
EnforcedStyleForEmptyBraces: no_space
|
13
|
+
|
7
14
|
Metrics/AbcSize:
|
8
15
|
Max: 20
|
9
16
|
|
17
|
+
Metrics/BlockLength:
|
18
|
+
Exclude:
|
19
|
+
- "spec/**/*"
|
20
|
+
|
10
21
|
Metrics/ClassLength:
|
11
22
|
Max: 200
|
12
23
|
|
24
|
+
Metrics/LineLength:
|
25
|
+
Max: 96
|
26
|
+
|
13
27
|
Metrics/MethodLength:
|
14
28
|
Max: 20
|
15
29
|
|
@@ -40,14 +54,3 @@ Style/PercentLiteralDelimiters:
|
|
40
54
|
|
41
55
|
Style/StringLiterals:
|
42
56
|
EnforcedStyle: double_quotes
|
43
|
-
|
44
|
-
Style/SpaceInsideHashLiteralBraces:
|
45
|
-
EnforcedStyle: no_space
|
46
|
-
EnforcedStyleForEmptyBraces: no_space
|
47
|
-
|
48
|
-
Style/IndentArray:
|
49
|
-
Enabled: false
|
50
|
-
|
51
|
-
Metrics/BlockLength:
|
52
|
-
Exclude:
|
53
|
-
- "spec/**/*"
|
data/.tool-versions
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
ruby 2.5.0
|
data/.travis.yml
CHANGED
@@ -1,7 +1,13 @@
|
|
1
1
|
sudo: false
|
2
2
|
language: ruby
|
3
3
|
rvm:
|
4
|
-
- 2.3.
|
5
|
-
|
4
|
+
- 2.3.6
|
5
|
+
- 2.4.3
|
6
|
+
- 2.5.0
|
6
7
|
services:
|
8
|
+
- postgresql
|
7
9
|
- redis-server
|
10
|
+
before_script:
|
11
|
+
- psql -c "CREATE DATABASE flippant_test;" -U postgres
|
12
|
+
addons:
|
13
|
+
postgresql: "9.6"
|
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,30 @@
|
|
1
|
-
## v0.
|
1
|
+
## v0.9.0 2018-03-20
|
2
|
+
|
3
|
+
### Enhancements
|
4
|
+
|
5
|
+
* [Flippant] - Add a new Postgres adatper, backed by PG and ActiveRecord pools.
|
6
|
+
* [Flippant] - Add `setup` to facilitate adapter setup (i.e. Postgres).
|
7
|
+
* [Flippant] - Modify `enable` and `disable` to prevent duplicate values and
|
8
|
+
operate atomically without a transaction.
|
9
|
+
* [Flippant] - Add `dump/1` and `load/1` functions for backups and portability.
|
10
|
+
|
11
|
+
### Changes
|
12
|
+
|
13
|
+
* [Flippant::Adapter] - Values are no longer guaranteed to be sorted. Some
|
14
|
+
adapters guarantee sorting, but race conditions prevent it in the Postgres
|
15
|
+
adapter, so it is no longer guaranteed.
|
16
|
+
|
17
|
+
## v0.6.0 2017-08-08
|
18
|
+
|
19
|
+
### Enhancements
|
20
|
+
|
21
|
+
* [Flippant] - Merge additional values when enabling features. This prevents
|
22
|
+
clobbering existing values in "last write wins" situations.
|
23
|
+
* [Flippant] - Support enabling or disabling of individual values. This makes it
|
24
|
+
possible to remove a single value from a group's rules.
|
25
|
+
* [Flippant] - Add `rename/2` for renaming existing features.
|
26
|
+
* [Flippant] - Add `exists?/1` for checking whether a feature exists at all,
|
27
|
+
and `exists?/2` for checking whether a feature exists for a particular group.
|
2
28
|
|
3
29
|
### Changes
|
4
30
|
|
@@ -7,12 +33,12 @@
|
|
7
33
|
exist. This forces an order of defining groups before enabling features, but
|
8
34
|
it will prevent trying to register groups that don't exist or with typos.
|
9
35
|
|
10
|
-
## v0.5.1
|
36
|
+
## v0.5.1 2017-01-26
|
11
37
|
|
12
38
|
### Changes
|
13
39
|
|
14
40
|
* [Flippant::Adapter::Redis] - Constructor uses keyword arguments.
|
15
41
|
|
16
|
-
## v0.5.0
|
42
|
+
## v0.5.0 2017-01-24
|
17
43
|
|
18
44
|
* Initial release, largely ported from sorentwo/flippant, the Elixir version.
|
data/README.md
CHANGED
@@ -1,41 +1,186 @@
|
|
1
1
|
# Flippant
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
TODO: Delete this and the text above, and describe your gem
|
3
|
+
Fast feature toggling for ruby applications, backed by Redis.
|
6
4
|
|
7
5
|
## Installation
|
8
6
|
|
9
7
|
Add this line to your application's Gemfile:
|
10
8
|
|
11
9
|
```ruby
|
12
|
-
gem 'flippant
|
10
|
+
gem 'flippant'
|
13
11
|
```
|
14
12
|
|
15
|
-
|
13
|
+
## Usage
|
14
|
+
|
15
|
+
Flippant composes three constructs to determine whether a feature is enabled:
|
16
16
|
|
17
|
-
|
17
|
+
* Actors - An actor can be any value, but typically it is a `User` or
|
18
|
+
some other object representing a user.
|
19
|
+
* Groups - Groups are used to identify and qualify actors. For example,
|
20
|
+
"everybody", "nobody", "admins", "staff", "testers" could all be groups names.
|
21
|
+
* Rules - Rules represent individual features which are evaluated against actors
|
22
|
+
and groups. For example, "search", "analytics", "super-secret-feature" could
|
23
|
+
all be rule names.
|
18
24
|
|
19
|
-
|
25
|
+
Group names may be either strings or symbols.
|
20
26
|
|
21
|
-
|
27
|
+
Let's walk through setting up a few example groups and rules. You'll want to
|
28
|
+
establish groups at startup, as they aren't likely to change (and defining
|
29
|
+
functions from a web interface isn't wise).
|
22
30
|
|
23
|
-
|
31
|
+
### Groups
|
24
32
|
|
25
|
-
|
33
|
+
First, a group that nobody can belong to. This is useful for disabling a feature
|
34
|
+
without deleting it:
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
Flippant.register("nobody", ->(actor, _) { false })
|
38
|
+
```
|
26
39
|
|
27
|
-
|
40
|
+
Now the opposite, a group that everybody can belong to:
|
28
41
|
|
29
|
-
|
42
|
+
```ruby
|
43
|
+
Flippant.register("everybody", ->(actor, _) { true })
|
44
|
+
```
|
30
45
|
|
31
|
-
To
|
46
|
+
To be more exclusive and define staff-only features we need a "staff" group:
|
32
47
|
|
33
|
-
|
48
|
+
```ruby
|
49
|
+
Flippant.register("staff", ->(actor, _) { actor.staff? })
|
50
|
+
```
|
34
51
|
|
35
|
-
|
52
|
+
Lastly, we'll roll out a feature out to a percentage of the actors:
|
36
53
|
|
54
|
+
```ruby
|
55
|
+
Flippant.register("adopters", ->(actor, buckets) { buckets.include?(actor.id % 10) })
|
56
|
+
```
|
37
57
|
|
38
|
-
|
58
|
+
To tidy up a bit, we can define the registered group detection functions in a separate module.
|
39
59
|
|
40
|
-
|
60
|
+
```ruby
|
61
|
+
module FeatureGroups
|
62
|
+
def self.premium_subscriber?(actor, _)
|
63
|
+
actor.premium_subscriber?
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.allowed_user?(actor, allowed_ids)
|
67
|
+
allowed_ids.include?(actor.id)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
Flippant.register("premium_subscriber", &FeatureGroups.method(:premium_subscriber?))
|
72
|
+
Flippant.register("allowed_user", &FeatureGroups.method(:allowed_user?))
|
73
|
+
```
|
74
|
+
|
75
|
+
|
76
|
+
With some core groups defined we now can set up some rules.
|
77
|
+
|
78
|
+
### Rules
|
79
|
+
|
80
|
+
Rules are comprised of a name, a group, and an optional set of values. Starting
|
81
|
+
with a simple example that builds on the groups we have already created, we'll
|
82
|
+
enable the "search" feature:
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
# Any staff can use the "search" feature
|
86
|
+
Flippant.enable("search", "staff")
|
87
|
+
|
88
|
+
# 30% of "adopters" can use the "search" feature as well
|
89
|
+
Flippant.enable("search", "adopters", [0, 1, 2])
|
90
|
+
```
|
91
|
+
|
92
|
+
Because rules are only built of binaries and simple data they can be defined or
|
93
|
+
refined at runtime. In fact, this is a crucial part of feature toggling. With a
|
94
|
+
web interface rules can be added, removed, or modified.
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
# Turn search off for adopters
|
98
|
+
Flippant.disable("search", "adopters")
|
99
|
+
|
100
|
+
# On second thought, enable it again for 10%
|
101
|
+
Flippant.enable("search", "adopters", [3])
|
102
|
+
```
|
103
|
+
|
104
|
+
With a set of groups and rules defined we can check whether a feature is
|
105
|
+
enabled for a particular actor:
|
106
|
+
|
107
|
+
```ruby
|
108
|
+
class User
|
109
|
+
attr_accessor :id, :is_staff
|
110
|
+
|
111
|
+
def initialize(id, is_staff)
|
112
|
+
@id = id
|
113
|
+
@is_staff = is_staff
|
114
|
+
end
|
115
|
+
|
116
|
+
def staff?
|
117
|
+
@is_staff
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
staff_user = User.new(1, true)
|
122
|
+
early_user = User.new(2, false)
|
123
|
+
later_user = User.new(3, false)
|
124
|
+
|
125
|
+
Flippant.enabled?("search", staff_user) #=> true, staff
|
126
|
+
Flippant.enabled?("search", early_user) #=> false, not an adopter
|
127
|
+
Flippant.enabled?("search", later_user) #=> true, is an adopter
|
128
|
+
```
|
129
|
+
|
130
|
+
If an actor qualifies for multiple groups and *any* of the rules evaluate to
|
131
|
+
true that feature will be enabled for them. Think of the "nobody" and
|
132
|
+
"everybody" groups that were defined earlier:
|
133
|
+
|
134
|
+
```ruby
|
135
|
+
Flippant.enable("search", "everybody")
|
136
|
+
Flippant.enable("search", "nobody")
|
137
|
+
|
138
|
+
Flippant.enabled?("search", User.new) #=> true
|
139
|
+
```
|
140
|
+
|
141
|
+
## Breakdown
|
142
|
+
|
143
|
+
Evaluating rules requires a round trip to the database. Clearly, with a lot of
|
144
|
+
rules it is inefficient to evaluate each one individually. There is a function
|
145
|
+
to help with this exact scenario:
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
Flippant.enable("search", "staff")
|
149
|
+
Flippant.enable("delete", "everybody")
|
150
|
+
Flippant.enable("invite", "nobody")
|
151
|
+
|
152
|
+
user = User.new(1, true)
|
153
|
+
Flippant.breakdown(user) #=> {
|
154
|
+
"search" => true,
|
155
|
+
"delete" => true,
|
156
|
+
"invite" => false
|
157
|
+
}
|
158
|
+
```
|
159
|
+
|
160
|
+
The breakdown is a simple hash of string keys to boolean values. This is
|
161
|
+
extremely handy for single page applications where you can serialize the
|
162
|
+
breakdown on boot or send it back from an endpoint as JSON.
|
163
|
+
|
164
|
+
## Configuration
|
165
|
+
|
166
|
+
Both Redis and Memory adapters are available for Flippant's registry storage. Memory
|
167
|
+
is the default.
|
168
|
+
|
169
|
+
The Memory adapter behaves identically to the Redis adapter, but will clear out
|
170
|
+
its registry whenever the application is reloaded, so it may be especially useful
|
171
|
+
in testing.
|
172
|
+
|
173
|
+
You may want to change this to Redis in production by overriding the `adapter` setting.
|
174
|
+
|
175
|
+
```ruby
|
176
|
+
# In Rails, for instance, add this to `config/initializers/flippant.rb`:
|
177
|
+
Flippant.adapter = if Rails.env.test?
|
178
|
+
Flippant::Adapter::Memory.new
|
179
|
+
else
|
180
|
+
Flippant::Adapter::Redis.new
|
181
|
+
end
|
182
|
+
```
|
183
|
+
|
184
|
+
## License
|
41
185
|
|
186
|
+
MIT License, see [LICENSE.txt](LICENSE.txt) for details.
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "benchmark/ips"
|
4
|
+
require "flippant"
|
5
|
+
require "active_record"
|
6
|
+
|
7
|
+
ActiveRecord::Base.establish_connection("postgres:///flippant_test")
|
8
|
+
|
9
|
+
memory = Flippant::Adapter::Memory.new
|
10
|
+
postgres = Flippant::Adapter::Postgres.new
|
11
|
+
redis = Flippant::Adapter::Redis.new
|
12
|
+
|
13
|
+
memory_enabled = lambda do
|
14
|
+
Flippant.adapter = memory unless Flippant.adapter == memory
|
15
|
+
|
16
|
+
Flippant.enabled?("search", nil)
|
17
|
+
end
|
18
|
+
|
19
|
+
postgres_enabled = lambda do
|
20
|
+
Flippant.adapter = postgres unless Flippant.adapter == postgres
|
21
|
+
|
22
|
+
Flippant.enabled?("search", nil)
|
23
|
+
end
|
24
|
+
|
25
|
+
redis_enabled = lambda do
|
26
|
+
Flippant.adapter = redis unless Flippant.adapter == redis
|
27
|
+
|
28
|
+
Flippant.enabled?("search", nil)
|
29
|
+
end
|
30
|
+
|
31
|
+
Flippant.configure do |config|
|
32
|
+
config.adapter = Flippant::Adapter::Postgres.new
|
33
|
+
end
|
34
|
+
|
35
|
+
Flippant.register("everybody", ->(_, _) { true })
|
36
|
+
Flippant.enable("search", "everybody")
|
37
|
+
|
38
|
+
Benchmark.ips do |x|
|
39
|
+
x.report "memory:enabled?", &memory_enabled
|
40
|
+
x.report "postgres:enabled?", &postgres_enabled
|
41
|
+
x.report "redis:enabled?", &redis_enabled
|
42
|
+
x.compare!
|
43
|
+
end
|
data/flippant.gemspec
CHANGED
@@ -1,19 +1,17 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
lib = File.expand_path("../lib", __FILE__)
|
1
|
+
lib = File.expand_path("lib", __dir__)
|
4
2
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
3
|
require "flippant/version"
|
6
4
|
|
7
5
|
Gem::Specification.new do |spec|
|
8
|
-
spec.name
|
9
|
-
spec.version
|
10
|
-
spec.authors
|
11
|
-
spec.email
|
6
|
+
spec.name = "flippant"
|
7
|
+
spec.version = Flippant::VERSION
|
8
|
+
spec.authors = ["Parker Selbert"]
|
9
|
+
spec.email = ["parker@sorentwo.com"]
|
12
10
|
|
13
|
-
spec.summary
|
11
|
+
spec.summary = "Fast feature toggling for applications"
|
14
12
|
spec.description = "Fast feature toggling for applications"
|
15
|
-
spec.homepage
|
16
|
-
spec.license
|
13
|
+
spec.homepage = "https://github.com/sorentwo/flippant-rb"
|
14
|
+
spec.license = "MIT"
|
17
15
|
|
18
16
|
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
19
17
|
f.match(%r{^spec/})
|
@@ -22,9 +20,12 @@ Gem::Specification.new do |spec|
|
|
22
20
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
23
21
|
spec.require_paths = ["lib"]
|
24
22
|
|
23
|
+
spec.add_development_dependency "activerecord", "~> 5.1"
|
24
|
+
spec.add_development_dependency "benchmark-ips"
|
25
25
|
spec.add_development_dependency "bundler"
|
26
|
+
spec.add_development_dependency "pg", "~> 1.0"
|
26
27
|
spec.add_development_dependency "rake"
|
27
|
-
spec.add_development_dependency "
|
28
|
-
spec.add_development_dependency "
|
29
|
-
spec.add_development_dependency "rubocop", "~> 0.
|
28
|
+
spec.add_development_dependency "redis", "~> 4.0"
|
29
|
+
spec.add_development_dependency "rspec", "~> 3.7"
|
30
|
+
spec.add_development_dependency "rubocop", "~> 0.52"
|
30
31
|
end
|
@@ -1,36 +1,42 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "monitor"
|
4
|
+
|
3
5
|
module Flippant
|
4
6
|
module Adapter
|
5
7
|
class Memory
|
6
|
-
attr_reader :table
|
8
|
+
attr_reader :monitor, :table
|
7
9
|
|
8
10
|
def initialize
|
11
|
+
@monitor = Monitor.new
|
12
|
+
|
9
13
|
clear
|
10
14
|
end
|
11
15
|
|
12
|
-
def
|
13
|
-
|
16
|
+
def setup
|
17
|
+
true
|
14
18
|
end
|
15
19
|
|
16
|
-
def
|
17
|
-
table
|
20
|
+
def add(feature)
|
21
|
+
table[feature] ||= {}
|
18
22
|
end
|
19
23
|
|
20
|
-
def
|
21
|
-
|
22
|
-
gkey = group.to_s
|
24
|
+
def breakdown(actor = nil)
|
25
|
+
return table if actor.nil?
|
23
26
|
|
24
|
-
|
25
|
-
|
26
|
-
table[fkey][gkey] = (table[fkey][gkey] | values).sort
|
27
|
+
table.each_with_object({}) do |(feature, _), memo|
|
28
|
+
memo[feature] = enabled?(feature, actor)
|
27
29
|
end
|
28
30
|
end
|
29
31
|
|
32
|
+
def clear
|
33
|
+
@table = Hash.new { |hash, key| hash[key] = {} }
|
34
|
+
end
|
35
|
+
|
30
36
|
def disable(feature, group, values = [])
|
31
|
-
rules = table[
|
37
|
+
rules = table[feature]
|
32
38
|
|
33
|
-
|
39
|
+
monitor.synchronize do
|
34
40
|
if values.any?
|
35
41
|
remove_values(rules, group, values)
|
36
42
|
else
|
@@ -39,24 +45,25 @@ module Flippant
|
|
39
45
|
end
|
40
46
|
end
|
41
47
|
|
42
|
-
def
|
43
|
-
|
44
|
-
|
48
|
+
def enable(feature, group, values = [])
|
49
|
+
fkey = feature
|
50
|
+
gkey = group.to_s
|
45
51
|
|
46
|
-
|
52
|
+
monitor.synchronize do
|
53
|
+
table[fkey][gkey] ||= []
|
54
|
+
table[fkey][gkey] = (table[fkey][gkey] | values).sort
|
55
|
+
end
|
47
56
|
end
|
48
57
|
|
49
58
|
def enabled?(feature, actor, registered = Flippant.registered)
|
50
|
-
table[
|
59
|
+
table[feature].any? do |group, values|
|
51
60
|
if (block = registered[group.to_s])
|
52
61
|
block.call(actor, values)
|
53
62
|
end
|
54
63
|
end
|
55
64
|
end
|
56
65
|
|
57
|
-
def exists?(feature, group
|
58
|
-
feature = normalize(feature)
|
59
|
-
|
66
|
+
def exists?(feature, group)
|
60
67
|
if group.nil?
|
61
68
|
table.key?(feature)
|
62
69
|
else
|
@@ -74,24 +81,29 @@ module Flippant
|
|
74
81
|
end
|
75
82
|
end
|
76
83
|
|
77
|
-
def
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
84
|
+
def load(loaded)
|
85
|
+
monitor.synchronize do
|
86
|
+
loaded.each do |feature, rules|
|
87
|
+
rules.each do |group, values|
|
88
|
+
table[feature][group] = values
|
89
|
+
end
|
90
|
+
end
|
82
91
|
end
|
83
92
|
end
|
84
93
|
|
85
|
-
def
|
86
|
-
|
94
|
+
def remove(feature)
|
95
|
+
table.delete(feature)
|
87
96
|
end
|
88
97
|
|
89
|
-
|
98
|
+
def rename(old_feature, new_feature)
|
99
|
+
old_feature = old_feature
|
100
|
+
new_feature = new_feature
|
90
101
|
|
91
|
-
|
92
|
-
feature.to_s.downcase.strip
|
102
|
+
table[new_feature] = table.delete(old_feature)
|
93
103
|
end
|
94
104
|
|
105
|
+
private
|
106
|
+
|
95
107
|
def remove_group(rules, to_remove)
|
96
108
|
rules.reject! { |(group, _)| group == to_remove.to_s }
|
97
109
|
end
|
@@ -0,0 +1,175 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
require "pg"
|
5
|
+
|
6
|
+
module Flippant
|
7
|
+
module Adapter
|
8
|
+
class Postgres
|
9
|
+
DEFAULT_TABLE = "flippant_features"
|
10
|
+
|
11
|
+
attr_reader :pool, :table
|
12
|
+
|
13
|
+
def initialize(pool: ActiveRecord::Base.connection_pool, table: DEFAULT_TABLE)
|
14
|
+
@pool = pool
|
15
|
+
@table = table
|
16
|
+
end
|
17
|
+
|
18
|
+
def add(feature)
|
19
|
+
exec("INSERT INTO #{table} (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", [feature])
|
20
|
+
end
|
21
|
+
|
22
|
+
def breakdown(actor = :all)
|
23
|
+
result = exec("SELECT jsonb_object_agg(name, rules) FROM #{table}")
|
24
|
+
object = JSON.parse(result.values.flatten.first || "{}")
|
25
|
+
|
26
|
+
object.each_with_object({}) do |(fkey, rules), memo|
|
27
|
+
memo[fkey] = actor == :all ? rules : Rules.enabled_for_actor?(rules, actor)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def clear
|
32
|
+
exec("TRUNCATE #{table} RESTART IDENTITY")
|
33
|
+
end
|
34
|
+
|
35
|
+
def disable(feature, group, values)
|
36
|
+
if values.empty?
|
37
|
+
exec("UPDATE #{table} SET rules = rules - $1 WHERE name = $2", [group, feature])
|
38
|
+
else
|
39
|
+
command = <<~SQL
|
40
|
+
UPDATE #{table} SET rules = jsonb_set(rules, $1, array_to_json(
|
41
|
+
ARRAY(
|
42
|
+
SELECT UNNEST(ARRAY(SELECT jsonb_array_elements(COALESCE(rules#>$1, '[]'::jsonb))))
|
43
|
+
EXCEPT
|
44
|
+
SELECT UNNEST(ARRAY(SELECT jsonb_array_elements($2)))
|
45
|
+
)
|
46
|
+
)::jsonb)
|
47
|
+
WHERE name = $3
|
48
|
+
SQL
|
49
|
+
|
50
|
+
exec(command, [encode_array([group]), encode_json(values), feature])
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def enable(feature, group, values)
|
55
|
+
command = <<~SQL
|
56
|
+
INSERT INTO #{table} AS t (name, rules) VALUES ($1, $2)
|
57
|
+
ON CONFLICT (name) DO UPDATE
|
58
|
+
SET rules = jsonb_set(t.rules, $3, array_to_json(
|
59
|
+
ARRAY(
|
60
|
+
SELECT DISTINCT(UNNEST(ARRAY(
|
61
|
+
SELECT jsonb_array_elements(COALESCE(t.rules#>$3, '[]'::jsonb))
|
62
|
+
) || $4))
|
63
|
+
)
|
64
|
+
)::jsonb)
|
65
|
+
SQL
|
66
|
+
|
67
|
+
exec(command, [feature,
|
68
|
+
encode_json(group => values),
|
69
|
+
encode_array([group]),
|
70
|
+
encode_array(values)])
|
71
|
+
end
|
72
|
+
|
73
|
+
def enabled?(feature, actor)
|
74
|
+
result = exec("SELECT rules FROM #{table} WHERE name = $1", [feature])
|
75
|
+
object = JSON.parse(result.values.flatten.first || "[]")
|
76
|
+
|
77
|
+
Rules.enabled_for_actor?(object, actor)
|
78
|
+
end
|
79
|
+
|
80
|
+
def exists?(feature, group = nil)
|
81
|
+
result =
|
82
|
+
if group.nil?
|
83
|
+
exec("SELECT EXISTS (SELECT 1 FROM #{table} WHERE name = $1)",
|
84
|
+
[feature])
|
85
|
+
else
|
86
|
+
exec("SELECT EXISTS (SELECT 1 FROM #{table} WHERE name = $1 " \
|
87
|
+
"AND rules ? $2)",
|
88
|
+
[feature, group])
|
89
|
+
end
|
90
|
+
|
91
|
+
result.values.first == [true]
|
92
|
+
end
|
93
|
+
|
94
|
+
def features(group = :all)
|
95
|
+
result =
|
96
|
+
if group == :all
|
97
|
+
exec("SELECT name FROM #{table} ORDER BY name ASC")
|
98
|
+
else
|
99
|
+
exec("SELECT name FROM #{table} WHERE rules ? $1 ORDER BY name ASC", [group])
|
100
|
+
end
|
101
|
+
|
102
|
+
result.values.flatten
|
103
|
+
end
|
104
|
+
|
105
|
+
def load(loaded)
|
106
|
+
transaction do |client|
|
107
|
+
loaded.each do |feature, rules|
|
108
|
+
client.exec_params(
|
109
|
+
"INSERT INTO #{table} AS t (name, rules) VALUES ($1, $2)",
|
110
|
+
[feature, encode_json(rules)]
|
111
|
+
)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def rename(old_name, new_name)
|
117
|
+
transaction do |client|
|
118
|
+
client.exec_params("DELETE FROM #{table} WHERE name = $1",
|
119
|
+
[new_name])
|
120
|
+
|
121
|
+
client.exec_params("UPDATE #{table} SET name = $1 WHERE name = $2",
|
122
|
+
[new_name, old_name])
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def remove(feature)
|
127
|
+
exec("DELETE FROM #{table} WHERE name = $1", [feature])
|
128
|
+
end
|
129
|
+
|
130
|
+
def setup
|
131
|
+
exec <<~SQL
|
132
|
+
CREATE TABLE IF NOT EXISTS #{table} (
|
133
|
+
name text NOT NULL CHECK (name <> ''),
|
134
|
+
rules jsonb NOT NULL DEFAULT '{}'::jsonb,
|
135
|
+
CONSTRAINT unique_name UNIQUE(name)
|
136
|
+
)
|
137
|
+
SQL
|
138
|
+
end
|
139
|
+
|
140
|
+
private
|
141
|
+
|
142
|
+
def conn
|
143
|
+
pool.with_connection do |connection|
|
144
|
+
client = connection.raw_connection
|
145
|
+
|
146
|
+
yield client
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def exec(sql, params = [])
|
151
|
+
conn do |client|
|
152
|
+
if params.empty?
|
153
|
+
client.exec(sql)
|
154
|
+
else
|
155
|
+
client.exec_params(sql, params)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def transaction(&block)
|
161
|
+
conn do |client|
|
162
|
+
client.transaction(&block)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def encode_array(value)
|
167
|
+
PG::TextEncoder::Array.new.encode(value)
|
168
|
+
end
|
169
|
+
|
170
|
+
def encode_json(value)
|
171
|
+
PG::TextEncoder::JSON.new.encode(value)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -9,35 +9,32 @@ module Flippant
|
|
9
9
|
|
10
10
|
DEFAULT_KEY = "flippant-features"
|
11
11
|
|
12
|
-
attr_reader :client, :
|
13
|
-
|
14
|
-
def_delegators :serializer, :dump, :load
|
12
|
+
attr_reader :client, :set_key, :serializer
|
15
13
|
|
16
14
|
def initialize(client: ::Redis.current,
|
17
|
-
|
15
|
+
set_key: DEFAULT_KEY,
|
18
16
|
serializer: Flippant.serializer)
|
19
17
|
@client = client
|
20
|
-
@key = key
|
21
18
|
@serializer = serializer
|
19
|
+
@set_key = set_key
|
20
|
+
end
|
21
|
+
|
22
|
+
def setup
|
23
|
+
true
|
22
24
|
end
|
23
25
|
|
24
26
|
def add(feature)
|
25
|
-
client.sadd(
|
27
|
+
client.sadd(set_key, feature)
|
26
28
|
end
|
27
29
|
|
28
|
-
def
|
29
|
-
|
30
|
-
|
31
|
-
client.del(namespace(feature))
|
30
|
+
def breakdown(actor = nil)
|
31
|
+
features(:all).each_with_object({}) do |fkey, memo|
|
32
|
+
memo[fkey] = actor.nil? ? feature_rules(fkey) : enabled?(fkey, actor)
|
32
33
|
end
|
33
34
|
end
|
34
35
|
|
35
|
-
def
|
36
|
-
|
37
|
-
|
38
|
-
change_values(namespace(feature), group) do |old|
|
39
|
-
(old | values).sort
|
40
|
-
end
|
36
|
+
def clear
|
37
|
+
client.smembers(set_key).each { |fkey| remove(fkey) }
|
41
38
|
end
|
42
39
|
|
43
40
|
def disable(feature, group, values = [])
|
@@ -54,32 +51,25 @@ module Flippant
|
|
54
51
|
maybe_cleanup(feature)
|
55
52
|
end
|
56
53
|
|
57
|
-
def
|
58
|
-
|
59
|
-
new_feature = normalize(new_feature)
|
60
|
-
old_namespaced = namespace(old_feature)
|
61
|
-
new_namespaced = namespace(new_feature)
|
54
|
+
def enable(feature, group, values = [])
|
55
|
+
add(feature)
|
62
56
|
|
63
|
-
|
64
|
-
|
65
|
-
client.srem(key, old_feature)
|
66
|
-
client.sadd(key, new_feature)
|
67
|
-
client.rename(old_namespaced, new_namespaced)
|
68
|
-
end
|
57
|
+
change_values(namespace(feature), group) do |old|
|
58
|
+
(old | values).sort
|
69
59
|
end
|
70
60
|
end
|
71
61
|
|
72
62
|
def enabled?(feature, actor, registered = Flippant.registered)
|
73
63
|
client.hgetall(namespace(feature)).any? do |group, values|
|
74
64
|
if (block = registered[group])
|
75
|
-
block.call(actor, load(values))
|
65
|
+
block.call(actor, serializer.load(values))
|
76
66
|
end
|
77
67
|
end
|
78
68
|
end
|
79
69
|
|
80
|
-
def exists?(feature, group
|
70
|
+
def exists?(feature, group)
|
81
71
|
if group.nil?
|
82
|
-
client.sismember(
|
72
|
+
client.sismember(set_key, feature)
|
83
73
|
else
|
84
74
|
client.hexists(namespace(feature), group)
|
85
75
|
end
|
@@ -87,7 +77,7 @@ module Flippant
|
|
87
77
|
|
88
78
|
def features(filter = :all)
|
89
79
|
if filter == :all
|
90
|
-
client.smembers(
|
80
|
+
client.smembers(set_key).sort
|
91
81
|
else
|
92
82
|
features(:all).select do |fkey|
|
93
83
|
client.hexists(namespace(fkey), filter)
|
@@ -95,14 +85,38 @@ module Flippant
|
|
95
85
|
end
|
96
86
|
end
|
97
87
|
|
98
|
-
def
|
99
|
-
|
100
|
-
|
88
|
+
def load(loaded)
|
89
|
+
client.multi do
|
90
|
+
loaded.each do |feature, rules|
|
91
|
+
client.sadd(set_key, feature)
|
92
|
+
|
93
|
+
rules.each do |group, values|
|
94
|
+
client.hset(namespace(feature), group, serializer.dump(values))
|
95
|
+
end
|
96
|
+
end
|
101
97
|
end
|
102
98
|
end
|
103
99
|
|
104
|
-
def
|
105
|
-
client.
|
100
|
+
def remove(feature)
|
101
|
+
client.multi do
|
102
|
+
client.srem(set_key, feature)
|
103
|
+
client.del(namespace(feature))
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def rename(old_feature, new_feature)
|
108
|
+
old_feature = old_feature
|
109
|
+
new_feature = new_feature
|
110
|
+
old_namespaced = namespace(old_feature)
|
111
|
+
new_namespaced = namespace(new_feature)
|
112
|
+
|
113
|
+
client.watch(old_namespaced, new_namespaced) do
|
114
|
+
client.multi do
|
115
|
+
client.srem(set_key, old_feature)
|
116
|
+
client.sadd(set_key, new_feature)
|
117
|
+
client.rename(old_namespaced, new_namespaced)
|
118
|
+
end
|
119
|
+
end
|
106
120
|
end
|
107
121
|
|
108
122
|
private
|
@@ -111,26 +125,22 @@ module Flippant
|
|
111
125
|
namespaced = namespace(feature)
|
112
126
|
|
113
127
|
client.hgetall(namespaced).each_with_object({}) do |(key, val), memo|
|
114
|
-
memo[key] = load(val)
|
128
|
+
memo[key] = serializer.load(val)
|
115
129
|
end
|
116
130
|
end
|
117
131
|
|
118
132
|
def get_values(namespaced, group)
|
119
|
-
load(client.hget(namespaced, group)) || []
|
133
|
+
serializer.load(client.hget(namespaced, group)) || []
|
120
134
|
end
|
121
135
|
|
122
136
|
def maybe_cleanup(feature)
|
123
137
|
namespaced = namespace(feature)
|
124
138
|
|
125
|
-
client.srem(
|
139
|
+
client.srem(set_key, feature) if client.hkeys(namespaced).empty?
|
126
140
|
end
|
127
141
|
|
128
142
|
def namespace(feature)
|
129
|
-
"#{
|
130
|
-
end
|
131
|
-
|
132
|
-
def normalize(feature)
|
133
|
-
feature.to_s.downcase.strip
|
143
|
+
"#{set_key}-#{feature}"
|
134
144
|
end
|
135
145
|
|
136
146
|
def change_values(namespaced, group)
|
@@ -139,7 +149,7 @@ module Flippant
|
|
139
149
|
new_values = yield(old_values)
|
140
150
|
|
141
151
|
client.multi do
|
142
|
-
client.hset(namespaced, group, dump(new_values))
|
152
|
+
client.hset(namespaced, group, serializer.dump(new_values))
|
143
153
|
end
|
144
154
|
end
|
145
155
|
end
|
data/lib/flippant/rules.rb
CHANGED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Flippant
|
4
|
+
module Rules
|
5
|
+
def self.enabled_for_actor?(rules, actor, groups = Flippant.registered)
|
6
|
+
rules.any? do |name, values|
|
7
|
+
if (block = groups[name])
|
8
|
+
block.call(actor, values)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
data/lib/flippant/version.rb
CHANGED
data/lib/flippant.rb
CHANGED
@@ -5,9 +5,11 @@ require "json"
|
|
5
5
|
module Flippant
|
6
6
|
autoload :Error, "flippant/errors"
|
7
7
|
autoload :Registry, "flippant/registry"
|
8
|
+
autoload :Rules, "flippant/rules"
|
8
9
|
|
9
10
|
module Adapter
|
10
11
|
autoload :Memory, "flippant/adapters/memory"
|
12
|
+
autoload :Postgres, "flippant/adapters/postgres"
|
11
13
|
autoload :Redis, "flippant/adapters/redis"
|
12
14
|
end
|
13
15
|
|
@@ -17,15 +19,10 @@ module Flippant
|
|
17
19
|
attr_writer :adapter, :registry, :serializer
|
18
20
|
|
19
21
|
def_delegators :adapter,
|
20
|
-
:add,
|
21
22
|
:breakdown,
|
22
23
|
:clear,
|
23
|
-
:disable,
|
24
|
-
:enabled?,
|
25
|
-
:exists?,
|
26
24
|
:features,
|
27
|
-
:
|
28
|
-
:rename
|
25
|
+
:setup
|
29
26
|
|
30
27
|
def_delegators :registry,
|
31
28
|
:register,
|
@@ -35,10 +32,46 @@ module Flippant
|
|
35
32
|
|
36
33
|
# Guarded Delegation
|
37
34
|
|
35
|
+
def add(feature)
|
36
|
+
adapter.add(normalize(feature))
|
37
|
+
end
|
38
|
+
|
39
|
+
def dump(path)
|
40
|
+
File.open(path, "w+") do |file|
|
41
|
+
file << serializer.dump(breakdown)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def exists?(features, group = nil)
|
46
|
+
adapter.exists?(normalize(features), group)
|
47
|
+
end
|
48
|
+
|
38
49
|
def enable(feature, group, values = [])
|
39
50
|
raise Flippant::Error, "Unknown group: #{group}" unless registered?(group)
|
40
51
|
|
41
|
-
adapter.enable(feature, group, values)
|
52
|
+
adapter.enable(normalize(feature), group, values)
|
53
|
+
end
|
54
|
+
|
55
|
+
def enabled?(feature, actor)
|
56
|
+
adapter.enabled?(normalize(feature), actor)
|
57
|
+
end
|
58
|
+
|
59
|
+
def disable(feature, group, values = [])
|
60
|
+
adapter.disable(normalize(feature), group, values)
|
61
|
+
end
|
62
|
+
|
63
|
+
def load(path)
|
64
|
+
File.open(path, "r") do |file|
|
65
|
+
adapter.load(serializer.load(file.read))
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def rename(old_name, new_name)
|
70
|
+
adapter.rename(normalize(old_name), normalize(new_name))
|
71
|
+
end
|
72
|
+
|
73
|
+
def remove(feature)
|
74
|
+
adapter.remove(normalize(feature))
|
42
75
|
end
|
43
76
|
|
44
77
|
# Configuration
|
@@ -66,4 +99,10 @@ module Flippant
|
|
66
99
|
else adapter.clear && registry.clear
|
67
100
|
end
|
68
101
|
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def normalize(feature)
|
106
|
+
feature.to_s.downcase.strip
|
107
|
+
end
|
69
108
|
end
|
metadata
CHANGED
@@ -1,15 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: flippant
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.9.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Parker Selbert
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2018-03-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5.1'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '5.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: benchmark-ips
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
13
41
|
- !ruby/object:Gem::Dependency
|
14
42
|
name: bundler
|
15
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -24,6 +52,20 @@ dependencies:
|
|
24
52
|
- - ">="
|
25
53
|
- !ruby/object:Gem::Version
|
26
54
|
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pg
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.0'
|
27
69
|
- !ruby/object:Gem::Dependency
|
28
70
|
name: rake
|
29
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -39,47 +81,47 @@ dependencies:
|
|
39
81
|
- !ruby/object:Gem::Version
|
40
82
|
version: '0'
|
41
83
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
84
|
+
name: redis
|
43
85
|
requirement: !ruby/object:Gem::Requirement
|
44
86
|
requirements:
|
45
87
|
- - "~>"
|
46
88
|
- !ruby/object:Gem::Version
|
47
|
-
version: '
|
89
|
+
version: '4.0'
|
48
90
|
type: :development
|
49
91
|
prerelease: false
|
50
92
|
version_requirements: !ruby/object:Gem::Requirement
|
51
93
|
requirements:
|
52
94
|
- - "~>"
|
53
95
|
- !ruby/object:Gem::Version
|
54
|
-
version: '
|
96
|
+
version: '4.0'
|
55
97
|
- !ruby/object:Gem::Dependency
|
56
|
-
name:
|
98
|
+
name: rspec
|
57
99
|
requirement: !ruby/object:Gem::Requirement
|
58
100
|
requirements:
|
59
101
|
- - "~>"
|
60
102
|
- !ruby/object:Gem::Version
|
61
|
-
version: '3.
|
103
|
+
version: '3.7'
|
62
104
|
type: :development
|
63
105
|
prerelease: false
|
64
106
|
version_requirements: !ruby/object:Gem::Requirement
|
65
107
|
requirements:
|
66
108
|
- - "~>"
|
67
109
|
- !ruby/object:Gem::Version
|
68
|
-
version: '3.
|
110
|
+
version: '3.7'
|
69
111
|
- !ruby/object:Gem::Dependency
|
70
112
|
name: rubocop
|
71
113
|
requirement: !ruby/object:Gem::Requirement
|
72
114
|
requirements:
|
73
115
|
- - "~>"
|
74
116
|
- !ruby/object:Gem::Version
|
75
|
-
version: '0.
|
117
|
+
version: '0.52'
|
76
118
|
type: :development
|
77
119
|
prerelease: false
|
78
120
|
version_requirements: !ruby/object:Gem::Requirement
|
79
121
|
requirements:
|
80
122
|
- - "~>"
|
81
123
|
- !ruby/object:Gem::Version
|
82
|
-
version: '0.
|
124
|
+
version: '0.52'
|
83
125
|
description: Fast feature toggling for applications
|
84
126
|
email:
|
85
127
|
- parker@sorentwo.com
|
@@ -90,12 +132,14 @@ files:
|
|
90
132
|
- ".gitignore"
|
91
133
|
- ".rspec"
|
92
134
|
- ".rubocop.yml"
|
135
|
+
- ".tool-versions"
|
93
136
|
- ".travis.yml"
|
94
137
|
- CHANGELOG.md
|
95
138
|
- Gemfile
|
96
139
|
- LICENSE.txt
|
97
140
|
- README.md
|
98
141
|
- Rakefile
|
142
|
+
- benchmarks/adapters.rb
|
99
143
|
- bin/console
|
100
144
|
- bin/rspec
|
101
145
|
- bin/rubocop
|
@@ -103,6 +147,7 @@ files:
|
|
103
147
|
- flippant.gemspec
|
104
148
|
- lib/flippant.rb
|
105
149
|
- lib/flippant/adapters/memory.rb
|
150
|
+
- lib/flippant/adapters/postgres.rb
|
106
151
|
- lib/flippant/adapters/redis.rb
|
107
152
|
- lib/flippant/errors.rb
|
108
153
|
- lib/flippant/registry.rb
|
@@ -128,7 +173,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
128
173
|
version: '0'
|
129
174
|
requirements: []
|
130
175
|
rubyforge_project:
|
131
|
-
rubygems_version: 2.
|
176
|
+
rubygems_version: 2.7.3
|
132
177
|
signing_key:
|
133
178
|
specification_version: 4
|
134
179
|
summary: Fast feature toggling for applications
|