copycopter_client 1.0.0.beta8 → 1.0.0.beta9

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