flippant 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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
|