copycopter_client 1.0.0.beta8 → 1.0.0.beta9

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.
@@ -71,13 +71,11 @@ Feature: Using copycopter in a rails app
71
71
  <%= @text %>
72
72
  """
73
73
  When I start the application
74
- And I wait for changes to be synchronized
75
74
  And I visit /users/
76
75
  Then the response should contain "Old content"
77
76
  When the the following blurbs are updated in the "abc123" project:
78
77
  | key | draft content |
79
78
  | en.users.index.controller-test | New content |
80
- And I wait for changes to be synchronized
81
79
  And I visit /users/
82
80
  Then the response should contain "New content"
83
81
 
@@ -98,8 +96,7 @@ Feature: Using copycopter in a rails app
98
96
  When I start the application
99
97
  And I visit /users/
100
98
  Then the response should contain "not found"
101
- When I wait for changes to be synchronized
102
- Then the "abc123" project should have the following blurbs:
99
+ And the "abc123" project should have the following blurbs:
103
100
  | key | draft content |
104
101
  | en.users.index.404 | not found |
105
102
  And the log should contain "Uploaded missing translations"
@@ -35,14 +35,23 @@ module CopycopterClient
35
35
  # If the +public+ option was set to +true+, this will use published blurbs.
36
36
  # Otherwise, draft content is fetched.
37
37
  #
38
- # @return [Hash] blurbs
38
+ # The client tracks ETags between download requests, and will return
39
+ # without yielding anything if the server returns a not modified response.
40
+ #
41
+ # @yield [Hash] downloaded blurbs
39
42
  # @raise [ConnectionError] if the connection fails
40
43
  def download
41
44
  connect do |http|
42
- response = http.get(uri(download_resource))
43
- check(response)
44
- log("Downloaded translations")
45
- JSON.parse(response.body)
45
+ request = Net::HTTP::Get.new(uri(download_resource))
46
+ request['If-None-Match'] = @etag
47
+ response = http.request(request)
48
+ if check(response)
49
+ log("Downloaded translations")
50
+ yield JSON.parse(response.body)
51
+ else
52
+ log("No new translations")
53
+ end
54
+ @etag = response['ETag']
46
55
  end
47
56
  end
48
57
 
@@ -101,11 +110,14 @@ module CopycopterClient
101
110
  end
102
111
 
103
112
  def check(response)
104
- if Net::HTTPNotFound === response
113
+ case response
114
+ when Net::HTTPNotFound
105
115
  raise InvalidApiKey, "Invalid API key: #{api_key}"
106
- end
107
-
108
- unless Net::HTTPSuccess === response
116
+ when Net::HTTPNotModified
117
+ false
118
+ when Net::HTTPSuccess
119
+ true
120
+ else
109
121
  raise ConnectionError, "#{response.code}: #{response.body}"
110
122
  end
111
123
  end
@@ -3,6 +3,7 @@ require 'copycopter_client/i18n_backend'
3
3
  require 'copycopter_client/client'
4
4
  require 'copycopter_client/sync'
5
5
  require 'copycopter_client/prefixed_logger'
6
+ require 'copycopter_client/request_sync'
6
7
 
7
8
  module CopycopterClient
8
9
  # Used to set up and modify settings for the client.
@@ -13,7 +14,7 @@ module CopycopterClient
13
14
  :http_open_timeout, :http_read_timeout, :client_name, :client_url,
14
15
  :client_version, :port, :protocol, :proxy_host, :proxy_pass,
15
16
  :proxy_port, :proxy_user, :secure, :polling_delay, :logger,
16
- :framework].freeze
17
+ :framework, :middleware].freeze
17
18
 
18
19
  # @return [String] The API key for your project, found on the project edit form.
19
20
  attr_accessor :api_key
@@ -72,6 +73,9 @@ module CopycopterClient
72
73
  # @return [Logger] Where to log messages. Must respond to same interface as Logger.
73
74
  attr_reader :logger
74
75
 
76
+ # @return the middleware stack, if any, which should respond to +use+
77
+ attr_accessor :middleware
78
+
75
79
  alias_method :secure?, :secure
76
80
 
77
81
  # Instantiated from {CopycopterClient.configure}. Sets defaults.
@@ -116,12 +120,19 @@ module CopycopterClient
116
120
  to_hash.merge(hash)
117
121
  end
118
122
 
119
- # Determines if the content will be editable
120
- # @return [Boolean] Returns +false+ if in a development environment, +true+ otherwise.
123
+ # Determines if the published or draft content will be used
124
+ # @return [Boolean] Returns +false+ if in a development or test
125
+ # environment, +true+ otherwise.
121
126
  def public?
122
127
  !(development_environments + test_environments).include?(environment_name)
123
128
  end
124
129
 
130
+ # Determines if the content will be editable
131
+ # @return [Boolean] Returns +true+ if in a development environment, +false+ otherwise.
132
+ def development?
133
+ development_environments.include?(environment_name)
134
+ end
135
+
125
136
  # Determines if the content will fetched from the server
126
137
  # @return [Boolean] Returns +true+ if in a test environment, +false+ otherwise.
127
138
  def test?
@@ -134,7 +145,7 @@ module CopycopterClient
134
145
  @applied
135
146
  end
136
147
 
137
- # Applies the configuration (internal).
148
+ # Applies the configuration (internal).
138
149
  #
139
150
  # Called automatically when {CopycopterClient.configure} is called in the application.
140
151
  #
@@ -147,6 +158,7 @@ module CopycopterClient
147
158
  I18n.backend = I18nBackend.new(sync)
148
159
  CopycopterClient.client = client
149
160
  CopycopterClient.sync = sync
161
+ middleware.use(RequestSync, :sync => sync) if middleware && development?
150
162
  @applied = true
151
163
  logger.info("Client #{VERSION} ready")
152
164
  logger.info("Environment Info: #{environment_info}")
@@ -17,6 +17,7 @@ module CopycopterClient
17
17
  config.environment_name = ::Rails.env
18
18
  config.logger = ::Rails.logger
19
19
  config.framework = "Rails: #{::Rails::VERSION::STRING}"
20
+ config.middleware = ::Rails.configuration.middleware
20
21
  end
21
22
  end
22
23
  end
@@ -0,0 +1,23 @@
1
+ module CopycopterClient
2
+ # Rack middleware that synchronizes with Copycopter 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 [Sync] :sync agent that should be flushed after each request
9
+ def initialize(app, options)
10
+ @app = app
11
+ @sync = options[:sync]
12
+ end
13
+
14
+ # Invokes the upstream Rack application and flushes the sync after each
15
+ # request.
16
+ def call(env)
17
+ @sync.download
18
+ response = @app.call(env)
19
+ @sync.flush
20
+ response
21
+ end
22
+ end
23
+ end
@@ -86,6 +86,17 @@ module CopycopterClient
86
86
  with_queued_changes do |queued|
87
87
  client.upload(queued)
88
88
  end
89
+ rescue ConnectionError => error
90
+ logger.error(error.message)
91
+ end
92
+
93
+ def download
94
+ client.download do |downloaded_blurbs|
95
+ downloaded_blurbs.reject! { |key, value| value == "" }
96
+ lock { @blurbs = downloaded_blurbs }
97
+ end
98
+ rescue ConnectionError => error
99
+ logger.error(error.message)
89
100
  end
90
101
 
91
102
  private
@@ -103,12 +114,8 @@ module CopycopterClient
103
114
  end
104
115
 
105
116
  def sync
106
- begin
107
- download
108
- flush
109
- rescue ConnectionError => error
110
- logger.error(error.message)
111
- end
117
+ download
118
+ flush
112
119
  ensure
113
120
  @pending = false
114
121
  end
@@ -124,12 +131,6 @@ module CopycopterClient
124
131
  yield(changes_to_push) if changes_to_push
125
132
  end
126
133
 
127
- def download
128
- downloaded_blurbs = client.download
129
- downloaded_blurbs.reject! { |key, value| value == "" }
130
- lock { @blurbs = downloaded_blurbs }
131
- end
132
-
133
134
  def lock(&block)
134
135
  @mutex.synchronize(&block)
135
136
  end
@@ -1,6 +1,6 @@
1
1
  module CopycopterClient
2
2
  # Client version
3
- VERSION = "1.0.0.beta8"
3
+ VERSION = "1.0.0.beta9"
4
4
 
5
5
  # API version being used to communicate with the server
6
6
  API_VERSION = "2.0"
@@ -29,28 +29,28 @@ describe CopycopterClient do
29
29
  it "should timeout when connecting" do
30
30
  project = add_project
31
31
  client = build_client(:api_key => project.api_key, :http_open_timeout => 4)
32
- client.download
32
+ client.download { |ignore| }
33
33
  http.open_timeout.should == 4
34
34
  end
35
35
 
36
36
  it "should timeout when reading" do
37
37
  project = add_project
38
38
  client = build_client(:api_key => project.api_key, :http_read_timeout => 4)
39
- client.download
39
+ client.download { |ignore| }
40
40
  http.read_timeout.should == 4
41
41
  end
42
42
 
43
43
  it "uses ssl when secure" do
44
44
  project = add_project
45
45
  client = build_client(:api_key => project.api_key, :secure => true)
46
- client.download
46
+ client.download { |ignore| }
47
47
  http.use_ssl.should == true
48
48
  end
49
49
 
50
50
  it "doesn't use ssl when insecure" do
51
51
  project = add_project
52
52
  client = build_client(:api_key => project.api_key, :secure => false)
53
- client.download
53
+ client.download { |ignore| }
54
54
  http.use_ssl.should == false
55
55
  end
56
56
 
@@ -66,9 +66,9 @@ describe CopycopterClient do
66
66
  ]
67
67
 
68
68
  errors.each do |original_error|
69
- http.stubs(:get).raises(original_error)
69
+ http.stubs(:request).raises(original_error)
70
70
  client = build_client_with_project
71
- expect { client.download }.
71
+ expect { client.download { |ignore| } }.
72
72
  to raise_error(CopycopterClient::ConnectionError) { |error|
73
73
  error.message.
74
74
  should == "#{original_error.class.name}: #{original_error.message}"
@@ -78,7 +78,8 @@ describe CopycopterClient do
78
78
 
79
79
  it "handles 500 errors from downloads with ConnectionError" do
80
80
  client = build_client(:api_key => 'raise_error')
81
- expect { client.download }.to raise_error(CopycopterClient::ConnectionError)
81
+ expect { client.download { |ignore| } }.
82
+ to raise_error(CopycopterClient::ConnectionError)
82
83
  end
83
84
 
84
85
  it "handles 500 errors from uploads with ConnectionError" do
@@ -88,7 +89,8 @@ describe CopycopterClient do
88
89
 
89
90
  it "handles 404 errors from downloads with ConnectionError" do
90
91
  client = build_client(:api_key => 'bogus')
91
- expect { client.download }.to raise_error(CopycopterClient::InvalidApiKey)
92
+ expect { client.download { |ignore| } }.
93
+ to raise_error(CopycopterClient::InvalidApiKey)
92
94
  end
93
95
 
94
96
  it "handles 404 errors from uploads with ConnectionError" do
@@ -109,8 +111,10 @@ describe CopycopterClient do
109
111
  'key.two' => "expected two"
110
112
  }
111
113
  })
114
+ client = build_client(:api_key => project.api_key, :public => true)
115
+ blurbs = nil
112
116
 
113
- blurbs = build_client(:api_key => project.api_key, :public => true).download
117
+ client.download { |yielded| blurbs = yielded }
114
118
 
115
119
  blurbs.should == {
116
120
  'key.one' => 'expected one',
@@ -121,7 +125,7 @@ describe CopycopterClient do
121
125
  it "logs that it performed a download" do
122
126
  logger = FakeLogger.new
123
127
  client = build_client_with_project(:logger => logger)
124
- client.download
128
+ client.download { |ignore| }
125
129
  logger.should have_entry(:info, "Downloaded translations")
126
130
  end
127
131
 
@@ -137,8 +141,10 @@ describe CopycopterClient do
137
141
  'key.three' => "unexpected three"
138
142
  }
139
143
  })
144
+ client = build_client(:api_key => project.api_key, :public => false)
145
+ blurbs = nil
140
146
 
141
- blurbs = build_client(:api_key => project.api_key, :public => false).download
147
+ client.download { |yielded| blurbs = yielded }
142
148
 
143
149
  blurbs.should == {
144
150
  'key.one' => 'expected one',
@@ -146,6 +152,23 @@ describe CopycopterClient do
146
152
  }
147
153
  end
148
154
 
155
+ it "handles a 304 response when downloading" do
156
+ project = add_project
157
+ project.update('draft' => { 'key.one' => "expected one" })
158
+ logger = FakeLogger.new
159
+ client = build_client(:api_key => project.api_key,
160
+ :public => false,
161
+ :logger => logger)
162
+ yields = 0
163
+
164
+ 2.times do
165
+ client.download { |ignore| yields += 1 }
166
+ end
167
+
168
+ yields.should == 1
169
+ logger.should have_entry(:info, "No new translations")
170
+ end
171
+
149
172
  it "uploads defaults for missing blurbs in an existing project" do
150
173
  project = add_project
151
174
 
@@ -42,6 +42,7 @@ describe CopycopterClient::Configuration do
42
42
  it { should have_config_option(:api_key). overridable }
43
43
  it { should have_config_option(:polling_delay). overridable.default(300) }
44
44
  it { should have_config_option(:framework). overridable }
45
+ it { should have_config_option(:middleware). overridable }
45
46
 
46
47
  it "should provide default values for secure connections" do
47
48
  config = CopycopterClient::Configuration.new
@@ -105,12 +106,14 @@ describe CopycopterClient::Configuration do
105
106
  config.development_environments = %w(development)
106
107
  config.environment_name = 'production'
107
108
  config.should be_public
109
+ config.should_not be_development
108
110
  end
109
111
 
110
- it "should not be public in a development environment" do
112
+ it "should be development in a development environment" do
111
113
  config = CopycopterClient::Configuration.new
112
114
  config.development_environments = %w(staging)
113
115
  config.environment_name = 'staging'
116
+ config.should be_development
114
117
  config.should_not be_public
115
118
  end
116
119
 
@@ -249,3 +252,45 @@ describe CopycopterClient::Configuration, "applied when not testing" do
249
252
  end
250
253
  end
251
254
 
255
+ describe CopycopterClient::Configuration, "applied when developing with middleware" do
256
+ it_should_behave_like "applied configuration"
257
+
258
+ let(:middleware) { MiddlewareStack.new }
259
+
260
+ before do
261
+ subject.middleware = middleware
262
+ subject.environment_name = 'development'
263
+ subject.apply
264
+ end
265
+
266
+ it "adds the sync middleware" do
267
+ middleware.should include(CopycopterClient::RequestSync)
268
+ end
269
+ end
270
+
271
+ describe CopycopterClient::Configuration, "applied when developing without middleware" do
272
+ it_should_behave_like "applied configuration"
273
+
274
+ before do
275
+ subject.middleware = nil
276
+ subject.environment_name = 'development'
277
+ subject.apply
278
+ end
279
+ end
280
+
281
+ describe CopycopterClient::Configuration, "applied with middleware when not developing" do
282
+ it_should_behave_like "applied configuration"
283
+
284
+ let(:middleware) { MiddlewareStack.new }
285
+
286
+ before do
287
+ subject.middleware = middleware
288
+ subject.environment_name = 'test'
289
+ subject.apply
290
+ end
291
+
292
+ it "doesn't add the sync middleware" do
293
+ middleware.should_not include(CopycopterClient::RequestSync)
294
+ end
295
+ end
296
+
@@ -0,0 +1,27 @@
1
+ require 'spec_helper'
2
+
3
+ describe CopycopterClient::RequestSync do
4
+
5
+ let(:sync) { {} }
6
+ let(:response) { 'response' }
7
+ let(:env) { 'env' }
8
+ let(:app) { stub('app', :call => response) }
9
+ before { sync.stubs(:flush => nil, :download => nil) }
10
+ subject { CopycopterClient::RequestSync.new(app, :sync => sync) }
11
+
12
+ it "invokes the upstream app" do
13
+ result = subject.call(env)
14
+ app.should have_received(:call).with(env)
15
+ result.should == response
16
+ end
17
+
18
+ it "flushes defaults" do
19
+ subject.call(env)
20
+ sync.should have_received(:flush)
21
+ end
22
+
23
+ it "downloads new copy" do
24
+ subject.call(env)
25
+ sync.should have_received(:download)
26
+ end
27
+ end
@@ -100,16 +100,46 @@ describe CopycopterClient::Sync do
100
100
  end
101
101
 
102
102
  it "uploads changes when flushed" do
103
- sync = build_sync(:polling_delay => 86400)
104
- sync.start
105
- sleep 2
103
+ sync = build_sync
106
104
  sync['test.key'] = 'test value'
105
+
107
106
  sync.flush
108
- sleep(2)
109
107
 
110
108
  client.uploaded.should == { 'test.key' => 'test value' }
111
109
  end
112
110
 
111
+ it "downloads changes" do
112
+ client['test.key'] = 'test value'
113
+ sync = build_sync
114
+
115
+ sync.download
116
+
117
+ sync['test.key'].should == 'test value'
118
+ end
119
+
120
+ it "handles connection errors when flushing" do
121
+ failure = "server is napping"
122
+ logger = FakeLogger.new
123
+ client.stubs(:upload).raises(CopycopterClient::ConnectionError.new(failure))
124
+ sync = build_sync(:logger => logger)
125
+ sync['upload.key'] = 'upload'
126
+
127
+ sync.flush
128
+
129
+ logger.should have_entry(:error, failure)
130
+ end
131
+
132
+ it "handles connection errors when downloading" do
133
+ failure = "server is napping"
134
+ logger = FakeLogger.new
135
+ client.stubs(:download).raises(CopycopterClient::ConnectionError.new(failure))
136
+ sync = build_sync(:logger => logger)
137
+
138
+ sync.download
139
+
140
+ logger.should have_entry(:error, failure)
141
+ end
142
+
113
143
  it "handles connection errors when polling" do
114
144
  failure = "server is napping"
115
145
  logger = FakeLogger.new
@@ -16,7 +16,8 @@ class FakeClient
16
16
  def download
17
17
  wait_for_delay
18
18
  @downloads += 1
19
- @data.dup
19
+ yield @data.dup
20
+ nil
20
21
  end
21
22
 
22
23
  def upload(data)
@@ -37,11 +37,17 @@ class FakeCopycopterApp < Sinatra::Base
37
37
  end
38
38
 
39
39
  get "/api/v2/projects/:api_key/published_blurbs" do |api_key|
40
- with_project(api_key) { |project| project.published.to_json }
40
+ with_project(api_key) do |project|
41
+ etag project.etag
42
+ project.published.to_json
43
+ end
41
44
  end
42
45
 
43
46
  get "/api/v2/projects/:api_key/draft_blurbs" do |api_key|
44
- with_project(api_key) { |project| project.draft.to_json }
47
+ with_project(api_key) do |project|
48
+ etag project.etag
49
+ project.draft.to_json
50
+ end
45
51
  end
46
52
 
47
53
  post "/api/v2/projects/:api_key/draft_blurbs" do |api_key|
@@ -66,10 +72,12 @@ class FakeCopycopterApp < Sinatra::Base
66
72
  @api_key = attrs['api_key']
67
73
  @draft = attrs['draft'] || {}
68
74
  @published = attrs['published'] || {}
75
+ @etag = attrs['etag'] || 1
69
76
  end
70
77
 
71
78
  def to_hash
72
79
  { 'api_key' => @api_key,
80
+ 'etag' => @etag,
73
81
  'draft' => @draft,
74
82
  'published' => @published }
75
83
  end
@@ -77,6 +85,7 @@ class FakeCopycopterApp < Sinatra::Base
77
85
  def update(attrs)
78
86
  @draft. update(attrs['draft']) if attrs['draft']
79
87
  @published.update(attrs['published']) if attrs['published']
88
+ @etag += 1
80
89
  self.class.save(self)
81
90
  end
82
91
 
@@ -89,6 +98,10 @@ class FakeCopycopterApp < Sinatra::Base
89
98
  self.class.save(self)
90
99
  end
91
100
 
101
+ def etag
102
+ @etag.to_s
103
+ end
104
+
92
105
  def self.create(api_key)
93
106
  project = Project.new('api_key' => api_key)
94
107
  save(project)
@@ -0,0 +1,13 @@
1
+ class MiddlewareStack
2
+ def initialize
3
+ @middlewares = []
4
+ end
5
+
6
+ def use(klass, *args)
7
+ @middlewares << klass.new('fake_app', *args)
8
+ end
9
+
10
+ def include?(klass)
11
+ @middlewares.any? { |middleware| klass === middleware }
12
+ end
13
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: copycopter_client
3
3
  version: !ruby/object:Gem::Version
4
- hash: 299253588
4
+ hash: 299253589
5
5
  prerelease: true
6
6
  segments:
7
7
  - 1
8
8
  - 0
9
9
  - 0
10
- - beta8
11
- version: 1.0.0.beta8
10
+ - beta9
11
+ version: 1.0.0.beta9
12
12
  platform: ruby
13
13
  authors:
14
14
  - thoughtbot
@@ -69,6 +69,7 @@ files:
69
69
  - lib/copycopter_client/prefixed_logger.rb
70
70
  - lib/copycopter_client/rails.rb
71
71
  - lib/copycopter_client/railtie.rb
72
+ - lib/copycopter_client/request_sync.rb
72
73
  - lib/copycopter_client/sync.rb
73
74
  - lib/copycopter_client/version.rb
74
75
  - lib/copycopter_client.rb
@@ -78,6 +79,7 @@ files:
78
79
  - spec/copycopter_client/helper_spec.rb
79
80
  - spec/copycopter_client/i18n_backend_spec.rb
80
81
  - spec/copycopter_client/prefixed_logger_spec.rb
82
+ - spec/copycopter_client/request_sync_spec.rb
81
83
  - spec/copycopter_client/sync_spec.rb
82
84
  - spec/spec.opts
83
85
  - spec/spec_helper.rb
@@ -90,6 +92,7 @@ files:
90
92
  - spec/support/fake_passenger.rb
91
93
  - spec/support/fake_resque_job.rb
92
94
  - spec/support/fake_unicorn.rb
95
+ - spec/support/middleware_stack.rb
93
96
  - features/rails.feature
94
97
  - features/step_definitions/copycopter_server_steps.rb
95
98
  - features/step_definitions/rails_steps.rb