abstract_feature_branch 1.0.0 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +4 -0
- data/README.md +61 -25
- data/RELEASE_NOTES.md +3 -3
- data/VERSION +1 -1
- data/abstract_feature_branch.gemspec +6 -2
- data/lib/abstract_feature_branch.rb +140 -119
- data/lib/ext/feature_branch.rb +12 -11
- data/lib/generators/templates/config/initializers/abstract_feature_branch.rb +6 -1
- data/spec/ext/feature_branch__feature_branch_per_user_spec.rb +109 -0
- data/spec/ext/feature_branch__feature_branch_spec.rb +0 -21
- metadata +17 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 10b3189e83e418a64b851efe68587d4277cc370d
|
4
|
+
data.tar.gz: 4089ac0e1c27b0ae0df91e17fa20312ec65b941b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f1238ecd211de61432380e60b0f85fbcd8a50c03cb9f54588ec0257ca0d6f9a8227087442f1749bfe3027de9108fe38c73eb857c4d681dc564b74492c03838eb
|
7
|
+
data.tar.gz: 6569fd5e1a1b298e8a9c7791bd0f0fdbdde1b5c7076d47373a4cd27e49ec994c2c25bd1a61b69387ccb00f7ce94de7f97883e7cbc8199a91f888f3ebddd9fab4
|
data/.travis.yml
CHANGED
@@ -18,8 +18,12 @@ matrix:
|
|
18
18
|
gemfile: ruby187.Gemfile
|
19
19
|
- rvm: 1.8.7
|
20
20
|
gemfile: Gemfile
|
21
|
+
- rvm: 1.8.7
|
22
|
+
gemfile: ruby187.Gemfile
|
21
23
|
- rvm: ree
|
22
24
|
gemfile: Gemfile
|
25
|
+
- rvm: jruby-18mode
|
26
|
+
gemfile: Gemfile
|
23
27
|
- rvm: jruby-18mode
|
24
28
|
gemfile: ruby187.Gemfile
|
25
29
|
- rvm: jruby-19mode
|
data/README.md
CHANGED
@@ -29,6 +29,7 @@ Requirements
|
|
29
29
|
------------
|
30
30
|
- Ruby ~> 2.0.0, ~> 1.9 or ~> 1.8.7
|
31
31
|
- (Optional) Rails ~> 4.0.0, ~> 3.0 or ~> 2.0
|
32
|
+
- (Optional) Redis server
|
32
33
|
|
33
34
|
Setup
|
34
35
|
-----
|
@@ -109,18 +110,6 @@ single-line logic:
|
|
109
110
|
|
110
111
|
Note that <code>feature_branch</code> returns nil and does not execute the block if the feature is disabled or non-existent.
|
111
112
|
|
112
|
-
- Declaratively feature branch two paths of logic, one that runs when feature1 is enabled and one that runs when it is disabled:
|
113
|
-
|
114
|
-
> feature_branch :feature1,
|
115
|
-
> :true => lambda {
|
116
|
-
> # perform logic
|
117
|
-
> },
|
118
|
-
> :false => lambda {
|
119
|
-
> # perform alternate logic
|
120
|
-
> }
|
121
|
-
|
122
|
-
Note that <code>feature_branch</code> executes the false branch if the feature is non-existent.
|
123
|
-
|
124
113
|
- Imperatively check if a feature is enabled or not:
|
125
114
|
|
126
115
|
> if feature_enabled?(:feature1)
|
@@ -131,6 +120,41 @@ Note that <code>feature_branch</code> executes the false branch if the feature i
|
|
131
120
|
|
132
121
|
Note that <code>feature_enabled?</code> returns false if the feature is disabled and nil if the feature is non-existent (practically the same effect, but nil can sometimes be useful to detect if a feature is referenced).
|
133
122
|
|
123
|
+
### Per-User Feature Enablement
|
124
|
+
|
125
|
+
It is possible to restrict enablement of features per specific users. This works in concert with having a feature enabled
|
126
|
+
in features.yml (or one of the overrides like features.local.yml or environment variable overrides)
|
127
|
+
|
128
|
+
1. Use <code>toggle_features_for_user</code> in Ruby code to enable features per user ID (e.g. email address or database ID). This loads Redis client gem into memory and stores per-user feature configuration in Redis.
|
129
|
+
In the example below, current_user is a method that provides the current signed in user (e.g. using Rails [Devise] (https://github.com/plataformatec/devise) library).
|
130
|
+
|
131
|
+
> user_id = current_user.email
|
132
|
+
> AbstractFeatureBranch.toggle_features_for_user(user_id, :feature1 => true, :feature2 => false, :feature3 => true, :feature5 => true)
|
133
|
+
|
134
|
+
Use alternate version of <code>feature_branch</code> and <code>feature_enabled?</code> passing extra <code>user_id</code> argument
|
135
|
+
|
136
|
+
Examples:
|
137
|
+
|
138
|
+
> feature_branch :feature1, current_user.email do
|
139
|
+
> # THIS WILL EXECUTE
|
140
|
+
> end
|
141
|
+
|
142
|
+
> if feature_enabled?(:feature2, current_user.email)
|
143
|
+
> # THIS ONE WILL NOT EXECUTE
|
144
|
+
> else
|
145
|
+
> # THIS ONE WILL EXECUTE
|
146
|
+
> end
|
147
|
+
|
148
|
+
> feature_branch :feature1, another_user.email do
|
149
|
+
> # THIS WILL NOT EXECUTE
|
150
|
+
> end
|
151
|
+
|
152
|
+
> if feature_enabled?(:feature2, another_user.email)
|
153
|
+
> # THIS ONE WILL EXECUTE (assuming feature2 is enabled in features.yml)
|
154
|
+
> else
|
155
|
+
> # THIS ONE WILL NOT EXECUTE
|
156
|
+
> end
|
157
|
+
|
134
158
|
Recommendations
|
135
159
|
---------------
|
136
160
|
|
@@ -167,7 +191,7 @@ simply switching off the URL route to them. Example:
|
|
167
191
|
> :website
|
168
192
|
> )
|
169
193
|
|
170
|
-
- In Rails 4 and 3.1+ with the asset pipeline, wrap newly added CSS or JavaScript using .erb format. Example (renamed projects.css.scss to projects.css.scss.erb and wrapped CSS with an abstract feature branch block):
|
194
|
+
- In Rails 4 and 3.1+ with the asset pipeline, wrap newly added CSS or JavaScript using .erb format ([gotcha and alternative solution](#gotcha-with-abstract-feature-branching-in-css-and-js-files)). Example (renamed projects.css.scss to projects.css.scss.erb and wrapped CSS with an abstract feature branch block):
|
171
195
|
|
172
196
|
> <% feature_branch :project_gallery do %>
|
173
197
|
> .exclude_display {
|
@@ -191,11 +215,11 @@ simply switching off the URL route to them. Example:
|
|
191
215
|
it is **strongly recommended** that its feature branching code is plucked out of the codebase to simplify and improve
|
192
216
|
future maintainability given that it is no longer needed at that point.
|
193
217
|
|
194
|
-
- Once <code>config/features.yml</code> grows too big (e.g. 20+ features), it is **strongly recommended
|
218
|
+
- Once <code>config/features.yml</code> grows too big (e.g. 20+ features), it is **strongly recommended** to split it into
|
195
219
|
multiple context-specific feature files by utilizing the context generator mentioned above: <pre>rails g abstract_feature_branch:context context_path</pre>
|
196
220
|
|
197
221
|
- When working on a new feature locally that the developer does not want others on the team to see yet, the feature
|
198
|
-
can be enabled in <code>config/features.local.yml</code> only as it is git ignored
|
222
|
+
can be enabled in <code>config/features.local.yml</code> only as it is git ignored while the feature is disabled in <code>config/features.yml</code>
|
199
223
|
|
200
224
|
- When troubleshooting a deployed feature by simulating a non-development environment (e.g. staging or production) locally,
|
201
225
|
the developer can disable it temporarily in <code>config/features.local.yml</code> (git ignored) under the non-development environment,
|
@@ -231,7 +255,7 @@ Heroku
|
|
231
255
|
Environment variable overrides can be extremely helpful on Heroku as they allow developers to enable/disable features
|
232
256
|
at runtime without a redeploy.
|
233
257
|
|
234
|
-
Examples
|
258
|
+
### Examples
|
235
259
|
|
236
260
|
Enabling a new feature without a redeploy:
|
237
261
|
<pre>heroku config:add ABSTRACT_FEATURE_BRANCH_FEATURE3=true -a heroku_application_name</pre>
|
@@ -242,12 +266,12 @@ Disabling a buggy recently deployed feature without a redeploy:
|
|
242
266
|
Removing an environment variable override:
|
243
267
|
<pre>heroku config:remove ABSTRACT_FEATURE_BRANCH_FEATURE2 -a heroku_application_name</pre>
|
244
268
|
|
245
|
-
Recommendation
|
269
|
+
### Recommendation
|
246
270
|
|
247
271
|
It is recommended that you use environment variable overrides on Heroku only as an emergency or temporary measure.
|
248
272
|
Afterward, make the change officially in config/features.yml, deploy, and remove the environment variable override for the long term.
|
249
273
|
|
250
|
-
Gotcha with abstract feature branching in CSS and JS files
|
274
|
+
### Gotcha with abstract feature branching in CSS and JS files
|
251
275
|
|
252
276
|
If you've used abstract feature branching in CSS or JS files via ERB, setting environment variable overrides won't
|
253
277
|
affect them as you need asset recompilation in addition to it, which can only be triggered by changing a CSS or JS
|
@@ -256,7 +280,7 @@ overrides have been recommended above as an emergency or temporary measure. If t
|
|
256
280
|
variable overrides to alter the style or JavaScript behavior of a page back and forth without a redeploy, **one solution**
|
257
281
|
is to do additional abstract feature branching in HTML templates (e.g. ERB or [HAML](http://haml.info) to
|
258
282
|
link to different stylesheets and JS files, use different CSS classes, or invoke different JavaScript methods per branch
|
259
|
-
of HTML for example.
|
283
|
+
of HTML for example.)
|
260
284
|
|
261
285
|
Feature Configuration Load Order
|
262
286
|
--------------------------------
|
@@ -275,6 +299,11 @@ Rails Initializer
|
|
275
299
|
|
276
300
|
Here is the content of the generated initializer (<code>config/initializers/abstract_feature_branch.rb</code>), which contains instructions on how to customize via [dependency injection](http://en.wikipedia.org/wiki/Dependency_injection):
|
277
301
|
|
302
|
+
> require 'redis'
|
303
|
+
>
|
304
|
+
> # Storage for user features, customizable over here (right now, only a Redis client is supported)
|
305
|
+
> AbstractFeatureBranch.user_features_storage = Redis.new
|
306
|
+
>
|
278
307
|
> # Application root where config/features.yml or config/features/ is found
|
279
308
|
> AbstractFeatureBranch.application_root = Rails.root
|
280
309
|
>
|
@@ -388,6 +417,16 @@ Note that the beautifier ignores comments at the top, but deletes entire line co
|
|
388
417
|
after invoking the rake task, **verify** that your feature file contents are to your satisfaction before committing the
|
389
418
|
task changes.
|
390
419
|
|
420
|
+
Feature Branches vs Branch by Abstraction
|
421
|
+
---------
|
422
|
+
|
423
|
+
Although feature branches and branching by abstraction are similar, there are different situations that recommend each approach.
|
424
|
+
|
425
|
+
Feature branching leverages your version control software (VCS) to create a branch that is independent of your main branch. Once you write your feature, you integrate it with the rest of your code base. Featuring branching is ideal for developing features that can be completed within the one or two iterations. But it can become cumbersome with larger features due to the fact your code is isolated and quickly falls out of sync with your main branch. You will have to regularly rebase with your main branch or devote substantial time to resolving merge conflicts.
|
426
|
+
|
427
|
+
Branching by abstraction, on the other hand, is ideal for substantial features, i.e. ones which take many iterations to complete. This approach to branching takes place outside of your VCS. Instead, you build your feature, but wrap the code inside configurable flags. These configuration flags will allow for different behavior, depending on the runtime environment. For example, a feature would be set to "on" when your app runs in development mode, but "off" when running in "production" mode. This approach avoids the pain of constantly rebasing or resolving a myriad of merge conflict when you do attempt to integrate your feature into the larger app.
|
428
|
+
|
429
|
+
|
391
430
|
Contributing to abstract_feature_branch
|
392
431
|
---------------------------------------
|
393
432
|
|
@@ -401,15 +440,12 @@ Contributing to abstract_feature_branch
|
|
401
440
|
|
402
441
|
Committers
|
403
442
|
---------------------------------------
|
404
|
-
[Annas "Andy" Maleh (Author)](https://github.com/AndyObtiva)
|
443
|
+
* [Annas "Andy" Maleh (Author)](https://github.com/AndyObtiva)
|
405
444
|
|
406
445
|
Contributors
|
407
446
|
---------------------------------------
|
408
|
-
[Christian Nennemann](https://github.com/XORwell)
|
409
|
-
|
410
|
-
Feedback and Idea Providers
|
411
|
-
---------------------------------------
|
412
|
-
[Ben Downey](https://github.com/bnd5k)
|
447
|
+
* [Christian Nennemann](https://github.com/XORwell)
|
448
|
+
* [Ben Downey](https://github.com/bnd5k)
|
413
449
|
|
414
450
|
Copyright
|
415
451
|
---------------------------------------
|
data/RELEASE_NOTES.md
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
Release Notes
|
2
2
|
-------------
|
3
3
|
|
4
|
-
Version 0.
|
5
|
-
- Added configuration support for feature cacheability
|
4
|
+
Version 1.0.0:
|
5
|
+
- Added configuration support for feature cacheability. Completed documentation, adding more details.
|
6
6
|
|
7
7
|
Version 0.9.0:
|
8
8
|
- Added support for runtime read of feature files in development to ease local testing (trading off performance)
|
@@ -52,4 +52,4 @@ Version 0.3.0:
|
|
52
52
|
|
53
53
|
Version 0.2.0:
|
54
54
|
- Support an "else" block to execute when a feature is off (via <code>:true</code> and <code>:false</code> lambda arguments)
|
55
|
-
- Support ability to check if a feature is enabled or not (via <code>feature_enabled?</code>)
|
55
|
+
- Support ability to check if a feature is enabled or not (via <code>feature_enabled?</code>)
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
1.
|
1
|
+
1.1.0
|
@@ -5,11 +5,11 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = "abstract_feature_branch"
|
8
|
-
s.version = "1.
|
8
|
+
s.version = "1.1.0"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Annas \"Andy\" Maleh"]
|
12
|
-
s.date = "
|
12
|
+
s.date = "2014-01-14"
|
13
13
|
s.description = "abstract_feature_branch is a Rails gem that enables developers to easily branch by abstraction as per this pattern:\nhttp://paulhammant.com/blog/branch_by_abstraction.html\n\nIt is a productivity and fault tolerance enhancing team practice that has been utilized by professional software development\nteams at large corporations, such as Sears and Groupon.\n\nIt provides the ability to wrap blocks of code with an abstract feature branch name, and then\nspecify in a configuration file which features to be switched on or off.\n\nThe goal is to build out upcoming features in the same source code repository branch, regardless of whether all are\ncompleted by the next release date or not, thus increasing team productivity by preventing integration delays.\nDevelopers then disable in-progress features until they are ready to be switched on in production, yet enable them\nlocally and in staging environments for in-progress testing.\n\nThis gives developers the added benefit of being able to switch a feature off after release should big problems arise\nfor a high risk feature.\n\nabstract_feature_branch additionally supports DDD's pattern of\nBounded Contexts by allowing developers to configure\ncontext-specific feature files if needed.\n"
|
14
14
|
s.extra_rdoc_files = [
|
15
15
|
"LICENSE.txt",
|
@@ -42,6 +42,7 @@ Gem::Specification.new do |s|
|
|
42
42
|
"ruby187.Gemfile",
|
43
43
|
"ruby187.Gemfile.lock",
|
44
44
|
"spec/abstract_feature_branch/file_beautifier_spec.rb",
|
45
|
+
"spec/ext/feature_branch__feature_branch_per_user_spec.rb",
|
45
46
|
"spec/ext/feature_branch__feature_branch_spec.rb",
|
46
47
|
"spec/ext/feature_branch__feature_enabled_spec.rb",
|
47
48
|
"spec/fixtures/application_development_config/config/features.reference.yml",
|
@@ -73,13 +74,16 @@ Gem::Specification.new do |s|
|
|
73
74
|
|
74
75
|
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
75
76
|
s.add_runtime_dependency(%q<deep_merge>, ["= 1.0.0"])
|
77
|
+
s.add_runtime_dependency(%q<redis>, ["= 3.0.6"])
|
76
78
|
s.add_development_dependency(%q<jeweler>, ["= 1.8.8"])
|
77
79
|
else
|
78
80
|
s.add_dependency(%q<deep_merge>, ["= 1.0.0"])
|
81
|
+
s.add_dependency(%q<redis>, ["= 3.0.6"])
|
79
82
|
s.add_dependency(%q<jeweler>, ["= 1.8.8"])
|
80
83
|
end
|
81
84
|
else
|
82
85
|
s.add_dependency(%q<deep_merge>, ["= 1.0.0"])
|
86
|
+
s.add_dependency(%q<redis>, ["= 3.0.6"])
|
83
87
|
s.add_dependency(%q<jeweler>, ["= 1.8.8"])
|
84
88
|
end
|
85
89
|
end
|
@@ -13,132 +13,153 @@ require 'logger' unless defined?(Rails) && Rails.logger
|
|
13
13
|
require 'deep_merge' unless {}.respond_to?(:deep_merge!)
|
14
14
|
|
15
15
|
module AbstractFeatureBranch
|
16
|
-
|
17
|
-
@application_root ||= initialize_application_root
|
18
|
-
end
|
19
|
-
def self.application_root=(path)
|
20
|
-
@application_root = path
|
21
|
-
end
|
22
|
-
def self.initialize_application_root
|
23
|
-
self.application_root = defined?(Rails) ? Rails.root : '.'
|
24
|
-
end
|
25
|
-
def self.application_environment
|
26
|
-
@application_environment ||= initialize_application_environment
|
27
|
-
end
|
28
|
-
def self.application_environment=(environment)
|
29
|
-
@application_environment = environment
|
30
|
-
end
|
31
|
-
def self.initialize_application_environment
|
32
|
-
self.application_environment = defined?(Rails) ? Rails.env.to_s : ENV['APP_ENV'] || 'development'
|
33
|
-
end
|
34
|
-
def self.logger
|
35
|
-
@logger ||= initialize_logger
|
36
|
-
end
|
37
|
-
def self.logger=(logger)
|
38
|
-
@logger = logger
|
39
|
-
end
|
40
|
-
def self.initialize_logger
|
41
|
-
self.logger = defined?(Rails) && Rails.logger ? Rails.logger : Logger.new(STDOUT)
|
42
|
-
end
|
43
|
-
def self.cacheable
|
44
|
-
@cacheable ||= initialize_cacheable
|
45
|
-
end
|
46
|
-
def self.cacheable=(cacheable)
|
47
|
-
@cacheable = cacheable
|
48
|
-
end
|
49
|
-
def self.initialize_cacheable
|
50
|
-
self.cacheable = {
|
51
|
-
:development => false,
|
52
|
-
:test => true,
|
53
|
-
:staging => true,
|
54
|
-
:production => true
|
55
|
-
}
|
56
|
-
end
|
57
|
-
def self.environment_variable_overrides
|
58
|
-
@environment_variable_overrides ||= load_environment_variable_overrides
|
59
|
-
end
|
60
|
-
def self.load_environment_variable_overrides
|
61
|
-
@environment_variable_overrides = featureize_keys(select_feature_keys(booleanize_values(downcase_keys(ENV))))
|
62
|
-
end
|
63
|
-
def self.local_features
|
64
|
-
@local_features ||= load_local_features
|
65
|
-
end
|
66
|
-
def self.load_local_features
|
67
|
-
@local_features = {}
|
68
|
-
Dir.glob(File.join(application_root, 'config', 'features', '**', '*.local.yml')).each do |feature_configuration_file|
|
69
|
-
@local_features.deep_merge!(downcase_feature_hash_keys(YAML.load_file(feature_configuration_file)))
|
70
|
-
end
|
71
|
-
main_local_features_file = File.join(application_root, 'config', 'features.local.yml')
|
72
|
-
@local_features.deep_merge!(downcase_feature_hash_keys(YAML.load_file(main_local_features_file))) if File.exists?(main_local_features_file)
|
73
|
-
@local_features
|
74
|
-
end
|
75
|
-
def self.features
|
76
|
-
@features ||= load_features
|
77
|
-
end
|
78
|
-
def self.load_features
|
79
|
-
@features = {}
|
80
|
-
Dir.glob(File.join(application_root, 'config', 'features', '**', '*.yml')).each do |feature_configuration_file|
|
81
|
-
@features.deep_merge!(downcase_feature_hash_keys(YAML.load_file(feature_configuration_file)))
|
82
|
-
end
|
83
|
-
main_features_file = File.join(application_root, 'config', 'features.yml')
|
84
|
-
@features.deep_merge!(downcase_feature_hash_keys(YAML.load_file(main_features_file))) if File.exists?(main_features_file)
|
85
|
-
@features
|
86
|
-
end
|
87
|
-
# performance optimization via caching of feature values resolved through environment variable overrides and local features
|
88
|
-
def self.environment_features(environment)
|
89
|
-
@environment_features ||= {}
|
90
|
-
@environment_features[environment] ||= load_environment_features(environment)
|
91
|
-
end
|
92
|
-
def self.load_environment_features(environment)
|
93
|
-
@environment_features ||= {}
|
94
|
-
features[environment] ||= {}
|
95
|
-
local_features[environment] ||= {}
|
96
|
-
@environment_features[environment] = features[environment].merge(local_features[environment]).merge(environment_variable_overrides)
|
97
|
-
end
|
98
|
-
def self.application_features
|
99
|
-
unload_application_features unless cacheable?
|
100
|
-
environment_features(application_environment)
|
101
|
-
end
|
102
|
-
def self.load_application_features
|
103
|
-
AbstractFeatureBranch.load_environment_variable_overrides
|
104
|
-
AbstractFeatureBranch.load_features
|
105
|
-
AbstractFeatureBranch.load_local_features
|
106
|
-
AbstractFeatureBranch.load_environment_features(application_environment)
|
107
|
-
end
|
108
|
-
def self.unload_application_features
|
109
|
-
@environment_variable_overrides = nil
|
110
|
-
@features = nil
|
111
|
-
@local_features = nil
|
112
|
-
@environment_features = nil
|
113
|
-
end
|
114
|
-
def self.cacheable?
|
115
|
-
value = downcase_keys(cacheable)[application_environment]
|
116
|
-
value = (application_environment != 'development') if value.nil?
|
117
|
-
value
|
118
|
-
end
|
16
|
+
ENV_FEATURE_PREFIX = "abstract_feature_branch_"
|
119
17
|
|
120
|
-
|
18
|
+
class << self
|
19
|
+
def application_root
|
20
|
+
@application_root ||= initialize_application_root
|
21
|
+
end
|
22
|
+
def application_root=(path)
|
23
|
+
@application_root = path
|
24
|
+
end
|
25
|
+
def initialize_application_root
|
26
|
+
self.application_root = defined?(Rails) ? Rails.root : '.'
|
27
|
+
end
|
28
|
+
def application_environment
|
29
|
+
@application_environment ||= initialize_application_environment
|
30
|
+
end
|
31
|
+
def application_environment=(environment)
|
32
|
+
@application_environment = environment
|
33
|
+
end
|
34
|
+
def initialize_application_environment
|
35
|
+
self.application_environment = defined?(Rails) ? Rails.env.to_s : ENV['APP_ENV'] || 'development'
|
36
|
+
end
|
37
|
+
def logger
|
38
|
+
@logger ||= initialize_logger
|
39
|
+
end
|
40
|
+
def logger=(logger)
|
41
|
+
@logger = logger
|
42
|
+
end
|
43
|
+
def initialize_logger
|
44
|
+
self.logger = defined?(Rails) && Rails.logger ? Rails.logger : Logger.new(STDOUT)
|
45
|
+
end
|
46
|
+
def cacheable
|
47
|
+
@cacheable ||= initialize_cacheable
|
48
|
+
end
|
49
|
+
def cacheable=(cacheable)
|
50
|
+
@cacheable = cacheable
|
51
|
+
end
|
52
|
+
def initialize_cacheable
|
53
|
+
self.cacheable = {
|
54
|
+
:development => false,
|
55
|
+
:test => true,
|
56
|
+
:staging => true,
|
57
|
+
:production => true
|
58
|
+
}
|
59
|
+
end
|
60
|
+
def user_features_storage
|
61
|
+
@user_features_storage ||= initialize_user_features_storage
|
62
|
+
end
|
63
|
+
def user_features_storage=(user_features_storage)
|
64
|
+
@user_features_storage = user_features_storage
|
65
|
+
end
|
66
|
+
def initialize_user_features_storage
|
67
|
+
require 'redis'
|
68
|
+
self.user_features_storage = Redis.new
|
69
|
+
end
|
70
|
+
def environment_variable_overrides
|
71
|
+
@environment_variable_overrides ||= load_environment_variable_overrides
|
72
|
+
end
|
73
|
+
def load_environment_variable_overrides
|
74
|
+
@environment_variable_overrides = featureize_keys(select_feature_keys(booleanize_values(downcase_keys(ENV))))
|
75
|
+
end
|
76
|
+
def local_features
|
77
|
+
@local_features ||= load_local_features
|
78
|
+
end
|
79
|
+
def load_local_features
|
80
|
+
@local_features = {}
|
81
|
+
Dir.glob(File.join(application_root, 'config', 'features', '**', '*.local.yml')).each do |feature_configuration_file|
|
82
|
+
@local_features.deep_merge!(downcase_feature_hash_keys(YAML.load_file(feature_configuration_file)))
|
83
|
+
end
|
84
|
+
main_local_features_file = File.join(application_root, 'config', 'features.local.yml')
|
85
|
+
@local_features.deep_merge!(downcase_feature_hash_keys(YAML.load_file(main_local_features_file))) if File.exists?(main_local_features_file)
|
86
|
+
@local_features
|
87
|
+
end
|
88
|
+
def features
|
89
|
+
@features ||= load_features
|
90
|
+
end
|
91
|
+
def load_features
|
92
|
+
@features = {}
|
93
|
+
Dir.glob(File.join(application_root, 'config', 'features', '**', '*.yml')).each do |feature_configuration_file|
|
94
|
+
@features.deep_merge!(downcase_feature_hash_keys(YAML.load_file(feature_configuration_file)))
|
95
|
+
end
|
96
|
+
main_features_file = File.join(application_root, 'config', 'features.yml')
|
97
|
+
@features.deep_merge!(downcase_feature_hash_keys(YAML.load_file(main_features_file))) if File.exists?(main_features_file)
|
98
|
+
@features
|
99
|
+
end
|
100
|
+
# performance optimization via caching of feature values resolved through environment variable overrides and local features
|
101
|
+
def environment_features(environment)
|
102
|
+
@environment_features ||= {}
|
103
|
+
@environment_features[environment] ||= load_environment_features(environment)
|
104
|
+
end
|
105
|
+
def load_environment_features(environment)
|
106
|
+
@environment_features ||= {}
|
107
|
+
features[environment] ||= {}
|
108
|
+
local_features[environment] ||= {}
|
109
|
+
@environment_features[environment] = features[environment].merge(local_features[environment]).merge(environment_variable_overrides)
|
110
|
+
end
|
111
|
+
def application_features
|
112
|
+
unload_application_features unless cacheable?
|
113
|
+
environment_features(application_environment)
|
114
|
+
end
|
115
|
+
def load_application_features
|
116
|
+
AbstractFeatureBranch.load_environment_variable_overrides
|
117
|
+
AbstractFeatureBranch.load_features
|
118
|
+
AbstractFeatureBranch.load_local_features
|
119
|
+
AbstractFeatureBranch.load_environment_features(application_environment)
|
120
|
+
end
|
121
|
+
def unload_application_features
|
122
|
+
@environment_variable_overrides = nil
|
123
|
+
@features = nil
|
124
|
+
@local_features = nil
|
125
|
+
@environment_features = nil
|
126
|
+
end
|
127
|
+
def cacheable?
|
128
|
+
value = downcase_keys(cacheable)[application_environment]
|
129
|
+
value = (application_environment != 'development') if value.nil?
|
130
|
+
value
|
131
|
+
end
|
132
|
+
def toggle_features_for_user(user_id, features)
|
133
|
+
features.each do |name, value|
|
134
|
+
if value
|
135
|
+
user_features_storage.sadd("#{ENV_FEATURE_PREFIX}#{name.to_s.downcase}", user_id)
|
136
|
+
else
|
137
|
+
user_features_storage.srem("#{ENV_FEATURE_PREFIX}#{name.to_s.downcase}", user_id)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
121
141
|
|
122
|
-
|
142
|
+
private
|
123
143
|
|
124
|
-
|
125
|
-
|
126
|
-
|
144
|
+
def featureize_keys(hash)
|
145
|
+
Hash[hash.map {|k, v| [k.sub(ENV_FEATURE_PREFIX, ''), v]}]
|
146
|
+
end
|
127
147
|
|
128
|
-
|
129
|
-
|
130
|
-
|
148
|
+
def select_feature_keys(hash)
|
149
|
+
hash.reject {|k, v| !k.start_with?(ENV_FEATURE_PREFIX)} # using reject for Ruby 1.8 compatibility as select returns an array in it
|
150
|
+
end
|
131
151
|
|
132
|
-
|
133
|
-
|
134
|
-
|
152
|
+
def booleanize_values(hash)
|
153
|
+
Hash[hash.map {|k, v| [k, v.to_s.downcase == 'true']}]
|
154
|
+
end
|
135
155
|
|
136
|
-
|
137
|
-
|
138
|
-
|
156
|
+
def downcase_keys(hash)
|
157
|
+
Hash[hash.map {|k, v| [k.to_s.downcase, v]}]
|
158
|
+
end
|
139
159
|
|
140
|
-
|
141
|
-
|
160
|
+
def downcase_feature_hash_keys(hash)
|
161
|
+
Hash[(hash || {}).map {|k, v| [k, v && downcase_keys(v)]}]
|
162
|
+
end
|
142
163
|
end
|
143
164
|
end
|
144
165
|
|
data/lib/ext/feature_branch.rb
CHANGED
@@ -1,24 +1,25 @@
|
|
1
1
|
class Object
|
2
2
|
raise 'Abstract feature branch conflicts with another Ruby library' if respond_to?(:feature_branch)
|
3
|
-
def self.feature_branch(feature_name,
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
branches[feature_branch_symbol_value].call
|
3
|
+
def self.feature_branch(feature_name, user_id = nil, &feature_work)
|
4
|
+
if feature_enabled?(feature_name, user_id)
|
5
|
+
feature_work.call
|
6
|
+
end
|
8
7
|
end
|
9
8
|
|
10
9
|
raise 'Abstract feature branch conflicts with another Ruby library' if respond_to?(:feature_enabled?)
|
11
|
-
def self.feature_enabled?(feature_name)
|
12
|
-
|
10
|
+
def self.feature_enabled?(feature_name, user_id = nil)
|
11
|
+
normalized_feature_name = feature_name.to_s.downcase
|
12
|
+
AbstractFeatureBranch.application_features[normalized_feature_name] &&
|
13
|
+
(user_id.nil? || AbstractFeatureBranch.user_features_storage.sismember("#{AbstractFeatureBranch::ENV_FEATURE_PREFIX}#{normalized_feature_name}", user_id))
|
13
14
|
end
|
14
15
|
|
15
16
|
raise 'Abstract feature branch conflicts with another Ruby library' if Object.new.respond_to?(:feature_branch)
|
16
|
-
def feature_branch(feature_name,
|
17
|
-
Object.feature_branch(feature_name.to_s,
|
17
|
+
def feature_branch(feature_name, user_id = nil, &feature_work)
|
18
|
+
Object.feature_branch(feature_name.to_s, user_id, &feature_work)
|
18
19
|
end
|
19
20
|
|
20
21
|
raise 'Abstract feature branch conflicts with another Ruby library' if Object.new.respond_to?(:feature_enabled?)
|
21
|
-
def feature_enabled?(feature_name)
|
22
|
-
Object.feature_enabled?(feature_name.to_s)
|
22
|
+
def feature_enabled?(feature_name, user_id = nil)
|
23
|
+
Object.feature_enabled?(feature_name.to_s, user_id)
|
23
24
|
end
|
24
25
|
end
|
@@ -1,3 +1,8 @@
|
|
1
|
+
require 'redis'
|
2
|
+
|
3
|
+
# Storage for user features, customizable over here (right now, only a Redis client is supported)
|
4
|
+
AbstractFeatureBranch.user_features_storage = Redis.new
|
5
|
+
|
1
6
|
# Application root where config/features.yml or config/features/ is found
|
2
7
|
AbstractFeatureBranch.application_root = Rails.root
|
3
8
|
|
@@ -17,4 +22,4 @@ AbstractFeatureBranch.cacheable = {
|
|
17
22
|
}
|
18
23
|
|
19
24
|
# Pre-load application features to improve performance of first web-page hit
|
20
|
-
AbstractFeatureBranch.load_application_features unless Rails.env.development?
|
25
|
+
AbstractFeatureBranch.load_application_features unless Rails.env.development?
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'feature_branch object extensions' do
|
4
|
+
before do
|
5
|
+
@app_env_backup = AbstractFeatureBranch.application_environment
|
6
|
+
@app_root_backup = AbstractFeatureBranch.application_root
|
7
|
+
AbstractFeatureBranch.logger.warn 'Environment variable ABSTRACT_FEATURE_BRANCH_FEATURE1 already set, potentially conflicting with another test' if ENV.keys.include?('ABSTRACT_FEATURE_BRANCH_FEATURE1')
|
8
|
+
AbstractFeatureBranch.logger.warn 'Environment variable Abstract_Feature_Branch_Feature2 already set, potentially conflicting with another test' if ENV.keys.include?('Abstract_Feature_Branch_Feature2')
|
9
|
+
AbstractFeatureBranch.logger.warn 'Environment variable abstract_feature_branch_feature3 already set, potentially conflicting with another test' if ENV.keys.include?('abstract_feature_branch_feature3')
|
10
|
+
AbstractFeatureBranch.user_features_storage.flushall
|
11
|
+
end
|
12
|
+
after do
|
13
|
+
ENV.delete('ABSTRACT_FEATURE_BRANCH_FEATURE1')
|
14
|
+
ENV.delete('Abstract_Feature_Branch_Feature2')
|
15
|
+
ENV.delete('abstract_feature_branch_feature3')
|
16
|
+
AbstractFeatureBranch.application_root = @app_root_backup
|
17
|
+
AbstractFeatureBranch.application_environment = @app_env_backup
|
18
|
+
AbstractFeatureBranch.unload_application_features
|
19
|
+
AbstractFeatureBranch.user_features_storage.flushall
|
20
|
+
end
|
21
|
+
describe '#feature_branch' do
|
22
|
+
context 'per user' do
|
23
|
+
it 'feature branches correctly after storing feature configuration per user in a separate process (ensuring persistence)' do
|
24
|
+
user_id = 'email1@example.com'
|
25
|
+
Process.fork do
|
26
|
+
AbstractFeatureBranch.initialize_user_features_storage
|
27
|
+
AbstractFeatureBranch.toggle_features_for_user(user_id, :feature1 => true, :feature2 => false, :feature3 => true, :feature5 => true)
|
28
|
+
end
|
29
|
+
Process.wait
|
30
|
+
features_enabled = []
|
31
|
+
feature_branch :feature1, user_id do
|
32
|
+
features_enabled << :feature1
|
33
|
+
end
|
34
|
+
feature_branch :feature2, user_id do
|
35
|
+
features_enabled << :feature2
|
36
|
+
end
|
37
|
+
feature_branch :feature3, user_id do
|
38
|
+
features_enabled << :feature3
|
39
|
+
end
|
40
|
+
feature_branch :feature5, user_id do
|
41
|
+
features_enabled << :feature5
|
42
|
+
end
|
43
|
+
feature_branch :feature5, 'otheruser@example.com' do
|
44
|
+
features_enabled << :feature5_otheruser
|
45
|
+
end
|
46
|
+
features_enabled.should include(:feature1)
|
47
|
+
features_enabled.should_not include(:feature2)
|
48
|
+
features_enabled.should_not include(:feature3)
|
49
|
+
features_enabled.should include(:feature5)
|
50
|
+
features_enabled.should_not include(:feature5_otheruser)
|
51
|
+
end
|
52
|
+
it 'update feature branching (disabling some features) after having stored feature configuration per user in a separate process (ensuring persistence)' do
|
53
|
+
user_id = 'email1@example.com'
|
54
|
+
Process.fork do
|
55
|
+
AbstractFeatureBranch.initialize_user_features_storage
|
56
|
+
AbstractFeatureBranch.toggle_features_for_user(user_id, :feature1 => true, :feature2 => false, :feature3 => true, :feature5 => true)
|
57
|
+
AbstractFeatureBranch.toggle_features_for_user(user_id, :feature1 => false, :feature2 => true, :feature3 => false, :feature5 => false)
|
58
|
+
end
|
59
|
+
Process.wait
|
60
|
+
features_enabled = []
|
61
|
+
feature_branch :feature1, user_id do
|
62
|
+
features_enabled << :feature1
|
63
|
+
end
|
64
|
+
feature_branch :feature2, user_id do
|
65
|
+
features_enabled << :feature2
|
66
|
+
end
|
67
|
+
feature_branch :feature3, user_id do
|
68
|
+
features_enabled << :feature3
|
69
|
+
end
|
70
|
+
feature_branch :feature5, user_id do
|
71
|
+
features_enabled << :feature5
|
72
|
+
end
|
73
|
+
features_enabled.should_not include(:feature1)
|
74
|
+
features_enabled.should include(:feature2)
|
75
|
+
features_enabled.should_not include(:feature3)
|
76
|
+
features_enabled.should_not include(:feature5)
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
end
|
81
|
+
describe 'self#feature_branch' do
|
82
|
+
after do
|
83
|
+
Object.send(:remove_const, :TestObject)
|
84
|
+
end
|
85
|
+
# No need to retest all instance test cases, just a spot check due to implementation reuse
|
86
|
+
it 'feature branches instance level behavior (case-insensitive feature names)' do
|
87
|
+
class TestObject
|
88
|
+
def self.features_enabled
|
89
|
+
@features_enabled ||= []
|
90
|
+
end
|
91
|
+
def self.hit_me
|
92
|
+
feature_branch :feature1 do
|
93
|
+
self.features_enabled << :feature1
|
94
|
+
end
|
95
|
+
feature_branch :feature2 do
|
96
|
+
self.features_enabled << :feature2
|
97
|
+
end
|
98
|
+
feature_branch :feature3 do
|
99
|
+
self.features_enabled << :feature3
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
TestObject.hit_me
|
104
|
+
TestObject.features_enabled.should include(:feature1)
|
105
|
+
TestObject.features_enabled.should include(:feature2)
|
106
|
+
TestObject.features_enabled.should_not include(:feature3)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -44,27 +44,6 @@ describe 'feature_branch object extensions' do
|
|
44
44
|
end
|
45
45
|
return_value.should be_nil
|
46
46
|
end
|
47
|
-
it 'supports an alternate branch of behavior for turned off features' do
|
48
|
-
feature_behaviors = []
|
49
|
-
feature_branch :feature1,
|
50
|
-
:true => lambda {feature_behaviors << :feature1_true},
|
51
|
-
:false => lambda {feature_behaviors << :feature1_false}
|
52
|
-
feature_branch :feature3,
|
53
|
-
:true => lambda {feature_behaviors << :feature3_true},
|
54
|
-
:false => lambda {feature_behaviors << :feature3_false}
|
55
|
-
feature_behaviors.should include(:feature1_true)
|
56
|
-
feature_behaviors.should_not include(:feature1_false)
|
57
|
-
feature_behaviors.should_not include(:feature3_true)
|
58
|
-
feature_behaviors.should include(:feature3_false)
|
59
|
-
end
|
60
|
-
it 'executes alternate branch for an invalid feature name' do
|
61
|
-
feature_behaviors = []
|
62
|
-
feature_branch :invalid_feature_that_does_not_exist,
|
63
|
-
:true => lambda {feature_behaviors << :main_branch},
|
64
|
-
:false => lambda {feature_behaviors << :alternate_branch}
|
65
|
-
feature_behaviors.should_not include(:main_branch)
|
66
|
-
feature_behaviors.should include(:alternate_branch)
|
67
|
-
end
|
68
47
|
it 'allows environment variables (case-insensitive booleans) to override configuration file' do
|
69
48
|
ENV['ABSTRACT_FEATURE_BRANCH_FEATURE1'] = 'FALSE'
|
70
49
|
ENV['Abstract_Feature_Branch_Feature2'] = 'False'
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: abstract_feature_branch
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Annas "Andy" Maleh
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2014-01-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: deep_merge
|
@@ -24,6 +24,20 @@ dependencies:
|
|
24
24
|
- - '='
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: 1.0.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: redis
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 3.0.6
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 3.0.6
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
42
|
name: jeweler
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -92,6 +106,7 @@ files:
|
|
92
106
|
- ruby187.Gemfile
|
93
107
|
- ruby187.Gemfile.lock
|
94
108
|
- spec/abstract_feature_branch/file_beautifier_spec.rb
|
109
|
+
- spec/ext/feature_branch__feature_branch_per_user_spec.rb
|
95
110
|
- spec/ext/feature_branch__feature_branch_spec.rb
|
96
111
|
- spec/ext/feature_branch__feature_enabled_spec.rb
|
97
112
|
- spec/fixtures/application_development_config/config/features.reference.yml
|