copy_tuner_client 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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