copy_tuner_client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. data/.gitignore +18 -0
  2. data/.rspec +2 -0
  3. data/.travis.yml +9 -0
  4. data/Appraisals +15 -0
  5. data/Gemfile +3 -0
  6. data/Gemfile.lock +161 -0
  7. data/README.md +4 -0
  8. data/Rakefile +28 -0
  9. data/copy_tuner_client.gemspec +33 -0
  10. data/features/rails.feature +270 -0
  11. data/features/step_definitions/copycopter_server_steps.rb +64 -0
  12. data/features/step_definitions/rails_steps.rb +172 -0
  13. data/features/support/env.rb +11 -0
  14. data/features/support/rails_server.rb +124 -0
  15. data/gemfiles/2.3.gemfile +7 -0
  16. data/gemfiles/2.3.gemfile.lock +105 -0
  17. data/gemfiles/3.0.gemfile +7 -0
  18. data/gemfiles/3.0.gemfile.lock +147 -0
  19. data/gemfiles/3.1.gemfile +11 -0
  20. data/gemfiles/3.1.gemfile.lock +191 -0
  21. data/init.rb +1 -0
  22. data/lib/copy_tuner_client/cache.rb +144 -0
  23. data/lib/copy_tuner_client/client.rb +136 -0
  24. data/lib/copy_tuner_client/configuration.rb +224 -0
  25. data/lib/copy_tuner_client/errors.rb +12 -0
  26. data/lib/copy_tuner_client/i18n_backend.rb +92 -0
  27. data/lib/copy_tuner_client/poller.rb +44 -0
  28. data/lib/copy_tuner_client/prefixed_logger.rb +45 -0
  29. data/lib/copy_tuner_client/process_guard.rb +92 -0
  30. data/lib/copy_tuner_client/rails.rb +21 -0
  31. data/lib/copy_tuner_client/railtie.rb +12 -0
  32. data/lib/copy_tuner_client/request_sync.rb +39 -0
  33. data/lib/copy_tuner_client/version.rb +7 -0
  34. data/lib/copy_tuner_client.rb +75 -0
  35. data/lib/tasks/copy_tuner_client_tasks.rake +20 -0
  36. data/spec/copy_tuner_client/cache_spec.rb +273 -0
  37. data/spec/copy_tuner_client/client_spec.rb +236 -0
  38. data/spec/copy_tuner_client/configuration_spec.rb +305 -0
  39. data/spec/copy_tuner_client/i18n_backend_spec.rb +157 -0
  40. data/spec/copy_tuner_client/poller_spec.rb +108 -0
  41. data/spec/copy_tuner_client/prefixed_logger_spec.rb +37 -0
  42. data/spec/copy_tuner_client/process_guard_spec.rb +118 -0
  43. data/spec/copy_tuner_client/request_sync_spec.rb +47 -0
  44. data/spec/copy_tuner_client_spec.rb +19 -0
  45. data/spec/spec_helper.rb +29 -0
  46. data/spec/support/client_spec_helpers.rb +8 -0
  47. data/spec/support/defines_constants.rb +44 -0
  48. data/spec/support/fake_client.rb +53 -0
  49. data/spec/support/fake_copy_tuner_app.rb +175 -0
  50. data/spec/support/fake_html_safe_string.rb +20 -0
  51. data/spec/support/fake_logger.rb +68 -0
  52. data/spec/support/fake_passenger.rb +27 -0
  53. data/spec/support/fake_resque_job.rb +18 -0
  54. data/spec/support/fake_unicorn.rb +13 -0
  55. data/spec/support/middleware_stack.rb +13 -0
  56. data/spec/support/writing_cache.rb +17 -0
  57. data/tmp/projects.json +1 -0
  58. metadata +389 -0
@@ -0,0 +1,44 @@
1
+ require 'thread'
2
+ require 'copy_tuner_client/cache'
3
+
4
+ module CopyTunerClient
5
+ # Starts a background thread that continually resynchronizes with the remote
6
+ # server using the given {Cache} after a set delay.
7
+ class Poller
8
+ # @param options [Hash]
9
+ # @option options [Logger] :logger where errors should be logged
10
+ # @option options [Fixnum] :polling_delay how long to wait in between requests
11
+ def initialize(cache, options)
12
+ @cache = cache
13
+ @polling_delay = options[:polling_delay]
14
+ @logger = options[:logger]
15
+ @stop = false
16
+ end
17
+
18
+ def start
19
+ Thread.new { poll } or logger.error("Couldn't start poller thread")
20
+ end
21
+
22
+ def stop
23
+ @stop = true
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :cache, :logger, :polling_delay
29
+
30
+ def poll
31
+ until @stop
32
+ cache.sync
33
+ logger.flush if logger.respond_to?(:flush)
34
+ delay
35
+ end
36
+ rescue InvalidApiKey => error
37
+ logger.error(error.message)
38
+ end
39
+
40
+ def delay
41
+ sleep(polling_delay)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ module CopyTunerClient
2
+ class PrefixedLogger
3
+ attr_reader :prefix, :original_logger
4
+
5
+ def initialize(prefix, logger)
6
+ @prefix = prefix
7
+ @original_logger = logger
8
+ end
9
+
10
+ def info(message = nil, &block)
11
+ log(:info, message, &block)
12
+ end
13
+
14
+ def debug(message = nil, &block)
15
+ log(:debug, message, &block)
16
+ end
17
+
18
+ def warn(message = nil, &block)
19
+ log(:warn, message, &block)
20
+ end
21
+
22
+ def error(message = nil, &block)
23
+ log(:error, message, &block)
24
+ end
25
+
26
+ def fatal(message = nil, &block)
27
+ log(:fatal, message, &block)
28
+ end
29
+
30
+ def flush
31
+ original_logger.flush if original_logger.respond_to?(:flush)
32
+ end
33
+
34
+ private
35
+
36
+ def log(severity, message, &block)
37
+ prefixed_message = "#{prefix} #{thread_info} #{message}"
38
+ original_logger.send(severity, prefixed_message, &block)
39
+ end
40
+
41
+ def thread_info
42
+ "[P:#{Process.pid}] [T:#{Thread.current.object_id}]"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,92 @@
1
+ module CopyTunerClient
2
+ # Starts the poller from a worker process, or register hooks for a spawner
3
+ # process (such as in Unicorn or Passenger). Also registers hooks for exiting
4
+ # processes and completing background jobs. Applications using the client
5
+ # will not need to interact with this class directly.
6
+ class ProcessGuard
7
+ # @param options [Hash]
8
+ # @option options [Logger] :logger where errors should be logged
9
+ def initialize(cache, poller, options)
10
+ @cache = cache
11
+ @poller = poller
12
+ @logger = options[:logger]
13
+ end
14
+
15
+ # Starts the poller or registers hooks
16
+ def start
17
+ if spawner?
18
+ register_spawn_hooks
19
+ else
20
+ register_exit_hooks
21
+ register_job_hooks
22
+ start_polling
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def start_polling
29
+ @poller.start
30
+ end
31
+
32
+ def spawner?
33
+ passenger_spawner? || unicorn_spawner?
34
+ end
35
+
36
+ def passenger_spawner?
37
+ $0.include?("ApplicationSpawner")
38
+ end
39
+
40
+ def unicorn_spawner?
41
+ $0.include?("unicorn") && !caller.any? { |line| line.include?("worker_loop") }
42
+ end
43
+
44
+ def register_spawn_hooks
45
+ if defined?(PhusionPassenger)
46
+ register_passenger_hook
47
+ elsif defined?(Unicorn::HttpServer)
48
+ register_unicorn_hook
49
+ end
50
+ end
51
+
52
+ def register_passenger_hook
53
+ @logger.info("Registered Phusion Passenger fork hook")
54
+ PhusionPassenger.on_event(:starting_worker_process) do |forked|
55
+ start_polling
56
+ end
57
+ end
58
+
59
+ def register_unicorn_hook
60
+ @logger.info("Registered Unicorn fork hook")
61
+ poller = @poller
62
+ Unicorn::HttpServer.class_eval do
63
+ alias_method :worker_loop_without_copy_tuner, :worker_loop
64
+ define_method :worker_loop do |worker|
65
+ poller.start
66
+ worker_loop_without_copy_tuner(worker)
67
+ end
68
+ end
69
+ end
70
+
71
+ def register_exit_hooks
72
+ at_exit do
73
+ @cache.flush
74
+ end
75
+ end
76
+
77
+ def register_job_hooks
78
+ if defined?(Resque::Job)
79
+ @logger.info("Registered Resque after_perform hook")
80
+ cache = @cache
81
+ Resque::Job.class_eval do
82
+ alias_method :perform_without_copy_tuner, :perform
83
+ define_method :perform do
84
+ job_was_performed = perform_without_copy_tuner
85
+ cache.flush
86
+ job_was_performed
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,21 @@
1
+ module CopyTunerClient
2
+ # Responsible for Rails initialization
3
+ module Rails
4
+ # Sets up the logger, environment, name, project root, and framework name
5
+ # for Rails applications. Must be called after framework initialization.
6
+ def self.initialize
7
+ CopyTunerClient.configure(false) do |config|
8
+ config.environment_name = ::Rails.env
9
+ config.logger = ::Rails.logger
10
+ config.framework = "Rails: #{::Rails::VERSION::STRING}"
11
+ config.middleware = ::Rails.configuration.middleware
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ if defined?(Rails::Railtie)
18
+ require 'copy_tuner_client/railtie'
19
+ else
20
+ CopyTunerClient::Rails.initialize
21
+ end
@@ -0,0 +1,12 @@
1
+ module CopyTunerClient
2
+ # Connects to integration points for Rails 3 applications
3
+ class Railtie < ::Rails::Railtie
4
+ initializer :initialize_copy_tuner_rails, :after => :before_initialize do
5
+ CopyTunerClient::Rails.initialize
6
+ end
7
+
8
+ rake_tasks do
9
+ load "tasks/copy_tuner_client_tasks.rake"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,39 @@
1
+ module CopyTunerClient
2
+ # Rack middleware that synchronizes with CopyTuner during each request.
3
+ #
4
+ # This is injected into the Rails middleware stack in development environments.
5
+ class RequestSync
6
+ # @param app [Rack] the upstream app into whose responses to inject the editor
7
+ # @param options [Hash]
8
+ # @option options [Cache] :cache agent that should be flushed after each request
9
+ def initialize(app, options)
10
+ @app = app
11
+ @cache = options[:cache]
12
+ @interval = options[:interval] || 1.minutes
13
+ @last_synced = Time.now.utc
14
+ end
15
+
16
+ # Invokes the upstream Rack application and flushes the cache after each
17
+ # request.
18
+ def call(env)
19
+ @cache.download unless asset_request?(env) or in_interval?
20
+ response = @app.call(env)
21
+ @cache.flush unless asset_request?(env) or in_interval?
22
+ update_last_synced unless in_interval?
23
+ response
24
+ end
25
+
26
+ private
27
+ def asset_request?(env)
28
+ env['PATH_INFO'] =~ /^\/assets/
29
+ end
30
+
31
+ def in_interval?
32
+ @last_synced + @interval > Time.now.utc
33
+ end
34
+
35
+ def update_last_synced
36
+ @last_synced = Time.now.utc
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,7 @@
1
+ module CopyTunerClient
2
+ # Client version
3
+ VERSION = '0.0.1'
4
+
5
+ # API version being used to communicate with the server
6
+ API_VERSION = '2.0'.freeze
7
+ end
@@ -0,0 +1,75 @@
1
+ require 'copy_tuner_client/version'
2
+ require 'copy_tuner_client/configuration'
3
+
4
+ # Top-level interface to the CopyTuner client.
5
+ #
6
+ # Most applications should only need to use the {.configure}
7
+ # method, which will setup all the pieces and begin synchronization when
8
+ # appropriate.
9
+ module CopyTunerClient
10
+ class << self
11
+ # @return [Configuration] current client configuration
12
+ # Must act like a hash and return sensible values for all CopyTuner
13
+ # configuration options. Usually set when {.configure} is called.
14
+ attr_accessor :configuration
15
+
16
+ # @return [Poller] instance used to poll for changes.
17
+ # This is set when {.configure} is called.
18
+ attr_accessor :poller
19
+ end
20
+
21
+ # Issues a new deploy, marking all draft blurbs as published.
22
+ # This is called when the copy_tuner:deploy rake task is invoked.
23
+ def self.deploy
24
+ client.deploy
25
+ end
26
+
27
+ # Issues a new export, returning yaml representation of blurb cache.
28
+ # This is called when the copy_tuner:export rake task is invoked.
29
+ def self.export
30
+ cache.export
31
+ end
32
+
33
+ # Starts the polling process.
34
+ def self.start_poller
35
+ poller.start
36
+ end
37
+
38
+ # Flush queued changed synchronously
39
+ def self.flush
40
+ cache.flush
41
+ end
42
+
43
+ def self.cache
44
+ CopyTunerClient.configuration.cache
45
+ end
46
+
47
+ def self.client
48
+ CopyTunerClient.configuration.client
49
+ end
50
+
51
+ # Call this method to modify defaults in your initializers.
52
+ #
53
+ # @example
54
+ # CopyTunerClient.configure do |config|
55
+ # config.api_key = '1234567890abcdef'
56
+ # config.host = 'your-copy-tuner-server.herokuapp.com'
57
+ # config.secure = true
58
+ # end
59
+ #
60
+ # @param apply [Boolean] (internal) whether the configuration should be applied yet.
61
+ #
62
+ # @yield [Configuration] the configuration to be modified
63
+ def self.configure(apply = true)
64
+ self.configuration ||= Configuration.new
65
+ yield configuration
66
+
67
+ if apply
68
+ configuration.apply
69
+ end
70
+ end
71
+ end
72
+
73
+ if defined? Rails
74
+ require 'copy_tuner_client/rails'
75
+ end
@@ -0,0 +1,20 @@
1
+ namespace :copy_tuner do
2
+ desc "Notify CopyTuner of a new deploy."
3
+ task :deploy => :environment do
4
+ CopyTunerClient.deploy
5
+ puts "Successfully marked all blurbs as published."
6
+ end
7
+
8
+ desc "Export CopyTuner blurbs to yaml."
9
+ task :export => :environment do
10
+ CopyTunerClient.cache.sync
11
+
12
+ if yml = CopyTunerClient.export
13
+ PATH = "config/locales/copy_tuner.yml"
14
+ File.new("#{Rails.root}/#{PATH}", 'w').write(yml)
15
+ puts "Successfully exported blurbs to #{PATH}."
16
+ else
17
+ puts "No blurbs have been cached."
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,273 @@
1
+ require 'spec_helper'
2
+
3
+ describe CopyTunerClient::Cache do
4
+ let(:client) { FakeClient.new }
5
+
6
+ def build_cache(config = {})
7
+ config[:logger] ||= FakeLogger.new
8
+ default_config = CopyTunerClient::Configuration.new.to_hash
9
+ CopyTunerClient::Cache.new(client, default_config.update(config))
10
+ end
11
+
12
+ it "provides access to downloaded data" do
13
+ client['en.test.key'] = 'expected'
14
+ client['en.test.other_key'] = 'expected'
15
+
16
+ cache = build_cache
17
+
18
+ cache.download
19
+
20
+ cache['en.test.key'].should == 'expected'
21
+ cache.keys.should =~ %w(en.test.key en.test.other_key)
22
+ end
23
+
24
+ it "doesn't upload without changes" do
25
+ cache = build_cache
26
+ cache.flush
27
+ client.should_not be_uploaded
28
+ end
29
+
30
+ it "uploads changes when flushed" do
31
+ cache = build_cache
32
+ cache['test.key'] = 'test value'
33
+
34
+ cache.flush
35
+
36
+ client.uploaded.should == { 'test.key' => 'test value' }
37
+ end
38
+
39
+ it "downloads changes" do
40
+ client['test.key'] = 'test value'
41
+ cache = build_cache
42
+
43
+ cache.download
44
+
45
+ cache['test.key'].should == 'test value'
46
+ end
47
+
48
+ it "downloads and uploads when synced" do
49
+ cache = build_cache
50
+ client['test.key'] = 'test value'
51
+ cache['other.key'] = 'other value'
52
+
53
+ cache.sync
54
+
55
+ client.uploaded.should == { 'other.key' => 'other value' }
56
+ cache['test.key'].should == 'test value'
57
+ end
58
+
59
+ it "handles connection errors when flushing" do
60
+ failure = "server is napping"
61
+ logger = FakeLogger.new
62
+ client.stubs(:upload).raises(CopyTunerClient::ConnectionError.new(failure))
63
+ cache = build_cache(:logger => logger)
64
+ cache['upload.key'] = 'upload'
65
+
66
+ cache.flush
67
+
68
+ logger.should have_entry(:error, failure)
69
+ end
70
+
71
+ it "handles connection errors when downloading" do
72
+ failure = "server is napping"
73
+ logger = FakeLogger.new
74
+ client.stubs(:download).raises(CopyTunerClient::ConnectionError.new(failure))
75
+ cache = build_cache(:logger => logger)
76
+
77
+ cache.download
78
+
79
+ logger.should have_entry(:error, failure)
80
+ end
81
+
82
+ it "blocks until the first download is complete" do
83
+ logger = FakeLogger.new
84
+ logger.stubs(:flush)
85
+ client.delay = 0.5
86
+ cache = build_cache(:logger => logger)
87
+
88
+ Thread.new { cache.download }
89
+
90
+ finished = false
91
+ Thread.new do
92
+ cache.wait_for_download
93
+ finished = true
94
+ end
95
+
96
+ sleep(1)
97
+
98
+ finished.should == true
99
+ logger.should have_entry(:info, "Waiting for first download")
100
+ logger.should have_received(:flush)
101
+ end
102
+
103
+ it "doesn't block if the first download fails" do
104
+ client.delay = 0.5
105
+ client.error = StandardError.new("Failure")
106
+ cache = build_cache
107
+
108
+ Thread.new { cache.download }
109
+
110
+ finished = false
111
+ Thread.new do
112
+ cache.wait_for_download
113
+ finished = true
114
+ end
115
+
116
+ sleep(1)
117
+
118
+ expect { cache.download }.to raise_error(StandardError, "Failure")
119
+ finished.should == true
120
+ end
121
+
122
+ it "doesn't block before downloading" do
123
+ logger = FakeLogger.new
124
+ cache = build_cache(:logger => logger)
125
+
126
+ finished = false
127
+ Thread.new do
128
+ cache.wait_for_download
129
+ finished = true
130
+ end
131
+
132
+ sleep(1)
133
+
134
+ finished.should == true
135
+ logger.should_not have_entry(:info, "Waiting for first download")
136
+ end
137
+
138
+ it "doesn't return blank copy" do
139
+ client['en.test.key'] = ''
140
+ cache = build_cache
141
+
142
+ cache.download
143
+
144
+ cache['en.test.key'].should be_nil
145
+ end
146
+
147
+ describe "given locked mutex" do
148
+ RSpec::Matchers.define :finish_after_unlocking do |mutex|
149
+ match do |thread|
150
+ sleep(0.1)
151
+
152
+ if thread.status === false
153
+ violated("finished before unlocking")
154
+ else
155
+ mutex.unlock
156
+ sleep(0.1)
157
+
158
+ if thread.status === false
159
+ true
160
+ else
161
+ violated("still running after unlocking")
162
+ end
163
+ end
164
+ end
165
+
166
+ def violated(failure)
167
+ @failure_message = failure
168
+ false
169
+ end
170
+
171
+ failure_message_for_should do
172
+ @failure_message
173
+ end
174
+ end
175
+
176
+ let(:mutex) { Mutex.new }
177
+ let(:cache) { build_cache }
178
+
179
+ before do
180
+ mutex.lock
181
+ Mutex.stubs(:new => mutex)
182
+ end
183
+
184
+ it "synchronizes read access to keys between threads" do
185
+ Thread.new { cache['test.key'] }.should finish_after_unlocking(mutex)
186
+ end
187
+
188
+ it "synchronizes read access to the key list between threads" do
189
+ Thread.new { cache.keys }.should finish_after_unlocking(mutex)
190
+ end
191
+
192
+ it "synchronizes write access to keys between threads" do
193
+ Thread.new { cache['test.key'] = 'value' }.should finish_after_unlocking(mutex)
194
+ end
195
+ end
196
+
197
+ it "flushes from the top level" do
198
+ cache = build_cache
199
+ CopyTunerClient.configure do |config|
200
+ config.cache = cache
201
+ end
202
+ cache.stubs(:flush)
203
+
204
+ CopyTunerClient.flush
205
+
206
+ cache.should have_received(:flush)
207
+ end
208
+
209
+ describe "#export" do
210
+ before do
211
+ save_blurbs
212
+ @cache = build_cache
213
+ @cache.download
214
+ end
215
+
216
+ let(:save_blurbs) {}
217
+
218
+ it "can be invoked from the top-level constant" do
219
+ CopyTunerClient.configure do |config|
220
+ config.cache = @cache
221
+ end
222
+ @cache.stubs(:export)
223
+
224
+ CopyTunerClient.export
225
+
226
+ @cache.should have_received(:export)
227
+ end
228
+
229
+ it "returns no yaml with no blurb keys" do
230
+ @cache.export.should == nil
231
+ end
232
+
233
+ context "with single-level blurb keys" do
234
+ let(:save_blurbs) do
235
+ client['key'] = 'test value'
236
+ client['other_key'] = 'other test value'
237
+ end
238
+
239
+ it "returns blurbs as yaml" do
240
+ exported = YAML.load(@cache.export)
241
+ exported['key'].should == 'test value'
242
+ exported['other_key'].should == 'other test value'
243
+ end
244
+ end
245
+
246
+ context "with multi-level blurb keys" do
247
+ let(:save_blurbs) do
248
+ client['en.test.key'] = 'en test value'
249
+ client['en.test.other_key'] = 'en other test value'
250
+ client['fr.test.key'] = 'fr test value'
251
+ end
252
+
253
+ it "returns blurbs as yaml" do
254
+ exported = YAML.load(@cache.export)
255
+ exported['en']['test']['key'].should == 'en test value'
256
+ exported['en']['test']['other_key'].should == 'en other test value'
257
+ exported['fr']['test']['key'].should == 'fr test value'
258
+ end
259
+ end
260
+
261
+ context "with conflicting blurb keys" do
262
+ let(:save_blurbs) do
263
+ client['en.test'] = 'test value'
264
+ client['en.test.key'] = 'other test value'
265
+ end
266
+
267
+ it "retains the new key" do
268
+ exported = YAML.load(@cache.export)
269
+ exported['en']['test']['key'].should == 'other test value'
270
+ end
271
+ end
272
+ end
273
+ end