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,136 @@
1
+ require 'net/http'
2
+ require 'net/https'
3
+ require 'copy_tuner_client/errors'
4
+
5
+ module CopyTunerClient
6
+ # Communicates with the CopyTuner server. This class is used to actually
7
+ # download and upload blurbs, as well as issuing deploys.
8
+ #
9
+ # A client is usually instantiated when {Configuration#apply} is called, and
10
+ # the application will not need to interact with it directly.
11
+ class Client
12
+ # These errors will be rescued when connecting CopyTuner.
13
+ HTTP_ERRORS = [Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
14
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
15
+ Net::ProtocolError, SocketError, OpenSSL::SSL::SSLError,
16
+ Errno::ECONNREFUSED]
17
+
18
+ # Usually instantiated from {Configuration#apply}. Copies options.
19
+ # @param options [Hash]
20
+ # @option options [String] :api_key API key of the project to connect to
21
+ # @option options [Fixnum] :port the port to connect to
22
+ # @option options [Boolean] :public whether to download draft or published content
23
+ # @option options [Fixnum] :http_read_timeout how long to wait before timing out when reading data from the socket
24
+ # @option options [Fixnum] :http_open_timeout how long to wait before timing out when opening the socket
25
+ # @option options [Boolean] :secure whether to use SSL
26
+ # @option options [Logger] :logger where to log transactions
27
+ # @option options [String] :ca_file path to root certificate file for ssl verification
28
+ def initialize(options)
29
+ [:api_key, :host, :port, :public, :http_read_timeout,
30
+ :http_open_timeout, :secure, :logger, :ca_file].each do |option|
31
+ instance_variable_set "@#{option}", options[option]
32
+ end
33
+ end
34
+
35
+ # Downloads all blurbs for the given api_key.
36
+ #
37
+ # If the +public+ option was set to +true+, this will use published blurbs.
38
+ # Otherwise, draft content is fetched.
39
+ #
40
+ # The client tracks ETags between download requests, and will return
41
+ # without yielding anything if the server returns a not modified response.
42
+ #
43
+ # @yield [Hash] downloaded blurbs
44
+ # @raise [ConnectionError] if the connection fails
45
+ def download
46
+ connect do |http|
47
+ request = Net::HTTP::Get.new(uri(download_resource))
48
+ request['If-None-Match'] = @etag
49
+ response = http.request(request)
50
+
51
+ if check response
52
+ log 'Downloaded translations'
53
+ yield JSON.parse(response.body)
54
+ else
55
+ log 'No new translations'
56
+ end
57
+
58
+ @etag = response['ETag']
59
+ end
60
+ end
61
+
62
+ # Uploads the given hash of blurbs as draft content.
63
+ # @param data [Hash] the blurbs to upload
64
+ # @raise [ConnectionError] if the connection fails
65
+ def upload(data)
66
+ connect do |http|
67
+ response = http.post(uri('draft_blurbs'), data.to_json, 'Content-Type' => 'application/json')
68
+ check response
69
+ log 'Uploaded missing translations'
70
+ end
71
+ end
72
+
73
+ # Issues a deploy, marking all draft content as published for this project.
74
+ # @raise [ConnectionError] if the connection fails
75
+ def deploy
76
+ connect do |http|
77
+ response = http.post(uri('deploys'), '')
78
+ check response
79
+ log 'Deployed'
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ attr_reader :host, :port, :api_key, :http_read_timeout,
86
+ :http_open_timeout, :secure, :logger, :ca_file
87
+
88
+ def public?
89
+ @public
90
+ end
91
+
92
+ def uri(resource)
93
+ "/api/v2/projects/#{api_key}/#{resource}"
94
+ end
95
+
96
+ def download_resource
97
+ if public?
98
+ 'published_blurbs'
99
+ else
100
+ 'draft_blurbs'
101
+ end
102
+ end
103
+
104
+ def connect
105
+ http = Net::HTTP.new(host, port)
106
+ http.open_timeout = http_open_timeout
107
+ http.read_timeout = http_read_timeout
108
+ http.use_ssl = secure
109
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
110
+ http.ca_file = ca_file
111
+
112
+ begin
113
+ yield http
114
+ rescue *HTTP_ERRORS => exception
115
+ raise ConnectionError, "#{exception.class.name}: #{exception.message}"
116
+ end
117
+ end
118
+
119
+ def check(response)
120
+ case response
121
+ when Net::HTTPNotFound
122
+ raise InvalidApiKey, "Invalid API key: #{api_key}"
123
+ when Net::HTTPNotModified
124
+ false
125
+ when Net::HTTPSuccess
126
+ true
127
+ else
128
+ raise ConnectionError, "#{response.code}: #{response.body}"
129
+ end
130
+ end
131
+
132
+ def log(message)
133
+ logger.info message
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,224 @@
1
+ require 'logger'
2
+ require 'copy_tuner_client/i18n_backend'
3
+ require 'copy_tuner_client/client'
4
+ require 'copy_tuner_client/cache'
5
+ require 'copy_tuner_client/process_guard'
6
+ require 'copy_tuner_client/poller'
7
+ require 'copy_tuner_client/prefixed_logger'
8
+ require 'copy_tuner_client/request_sync'
9
+
10
+ module CopyTunerClient
11
+ # Used to set up and modify settings for the client.
12
+ class Configuration
13
+
14
+ # These options will be present in the Hash returned by {#to_hash}.
15
+ OPTIONS = [:api_key, :development_environments, :environment_name, :host,
16
+ :http_open_timeout, :http_read_timeout, :client_name, :client_url,
17
+ :client_version, :port, :protocol, :proxy_host, :proxy_pass,
18
+ :proxy_port, :proxy_user, :secure, :polling_delay, :logger,
19
+ :framework, :middleware, :ca_file].freeze
20
+
21
+ # @return [String] The API key for your project, found on the project edit form.
22
+ attr_accessor :api_key
23
+
24
+ # @return [String] The host to connect to (defaults to +copy-tuner.com+).
25
+ attr_accessor :host
26
+
27
+ # @return [Fixnum] The port on which your CopyTuner server runs (defaults to +443+ for secure connections, +80+ for insecure connections).
28
+ attr_accessor :port
29
+
30
+ # @return [Boolean] +true+ for https connections, +false+ for http connections.
31
+ attr_accessor :secure
32
+
33
+ # @return [Fixnum] The HTTP open timeout in seconds (defaults to +2+).
34
+ attr_accessor :http_open_timeout
35
+
36
+ # @return [Fixnum] The HTTP read timeout in seconds (defaults to +5+).
37
+ attr_accessor :http_read_timeout
38
+
39
+ # @return [String, NilClass] The hostname of your proxy server (if using a proxy)
40
+ attr_accessor :proxy_host
41
+
42
+ # @return [String, Fixnum] The port of your proxy server (if using a proxy)
43
+ attr_accessor :proxy_port
44
+
45
+ # @return [String, NilClass] The username to use when logging into your proxy server (if using a proxy)
46
+ attr_accessor :proxy_user
47
+
48
+ # @return [String, NilClass] The password to use when logging into your proxy server (if using a proxy)
49
+ attr_accessor :proxy_pass
50
+
51
+ # @return [Array<String>] A list of environments in which content should be editable
52
+ attr_accessor :development_environments
53
+
54
+ # @return [Array<String>] A list of environments in which the server should not be contacted
55
+ attr_accessor :test_environments
56
+
57
+ # @return [String] The name of the environment the application is running in
58
+ attr_accessor :environment_name
59
+
60
+ # @return [String] The name of the client library being used to send notifications (defaults to +CopyTuner Client+)
61
+ attr_accessor :client_name
62
+
63
+ # @return [String, NilClass] The framework notifications are being sent from, if any (such as +Rails 2.3.9+)
64
+ attr_accessor :framework
65
+
66
+ # @return [String] The version of the client library being used to send notifications (such as +1.0.2+)
67
+ attr_accessor :client_version
68
+
69
+ # @return [String] The url of the client library being used
70
+ attr_accessor :client_url
71
+
72
+ # @return [Integer] The time, in seconds, in between each sync to the server. Defaults to +300+.
73
+ attr_accessor :polling_delay
74
+
75
+ # @return [Logger] Where to log messages. Must respond to same interface as Logger.
76
+ attr_reader :logger
77
+
78
+ # @return the middleware stack, if any, which should respond to +use+
79
+ attr_accessor :middleware
80
+
81
+ # @return [String] the path to a root certificate file used to verify ssl sessions. Default's to the root certificate file for copy-tuner.com.
82
+ attr_accessor :ca_file
83
+
84
+ # @return [Cache] instance used internally to synchronize changes.
85
+ attr_accessor :cache
86
+
87
+ # @return [Client] instance used to communicate with a CopyTuner Server.
88
+ attr_accessor :client
89
+
90
+ alias_method :secure?, :secure
91
+
92
+ # Instantiated from {CopyTunerClient.configure}. Sets defaults.
93
+ def initialize
94
+ self.client_name = 'CopyTuner Client'
95
+ self.client_url = 'https://rubygems.org/gems/copy_tuner_client'
96
+ self.client_version = VERSION
97
+ self.development_environments = %w(development staging)
98
+ self.host = 'copy-tuner.com'
99
+ self.http_open_timeout = 2
100
+ self.http_read_timeout = 5
101
+ self.logger = Logger.new($stdout)
102
+ self.polling_delay = 300
103
+ self.secure = false
104
+ self.test_environments = %w(test cucumber)
105
+ @applied = false
106
+ end
107
+
108
+ # Allows config options to be read like a hash
109
+ #
110
+ # @param [Symbol] option Key for a given attribute
111
+ # @return [Object] the given attribute
112
+ def [](option)
113
+ send(option)
114
+ end
115
+
116
+ # Returns a hash of all configurable options
117
+ # @return [Hash] configuration attributes
118
+ def to_hash
119
+ base_options = { :public => public? }
120
+
121
+ OPTIONS.inject(base_options) do |hash, option|
122
+ hash.merge option.to_sym => send(option)
123
+ end
124
+ end
125
+
126
+ # Returns a hash of all configurable options merged with +hash+
127
+ #
128
+ # @param [Hash] hash A set of configuration options that will take precedence over the defaults
129
+ # @return [Hash] the merged configuration hash
130
+ def merge(hash)
131
+ to_hash.merge hash
132
+ end
133
+
134
+ # Determines if the published or draft content will be used
135
+ # @return [Boolean] Returns +false+ if in a development or test
136
+ # environment, +true+ otherwise.
137
+ def public?
138
+ !(development_environments + test_environments).include?(environment_name)
139
+ end
140
+
141
+ # Determines if the content will be editable
142
+ # @return [Boolean] Returns +true+ if in a development environment, +false+ otherwise.
143
+ def development?
144
+ development_environments.include? environment_name
145
+ end
146
+
147
+ # Determines if the content will fetched from the server
148
+ # @return [Boolean] Returns +true+ if in a test environment, +false+ otherwise.
149
+ def test?
150
+ test_environments.include? environment_name
151
+ end
152
+
153
+ # Determines if the configuration has been applied (internal)
154
+ # @return [Boolean] Returns +true+ if applied, +false+ otherwise.
155
+ def applied?
156
+ @applied
157
+ end
158
+
159
+ # Applies the configuration (internal).
160
+ #
161
+ # Called automatically when {CopyTunerClient.configure} is called in the application.
162
+ #
163
+ # This creates the {I18nBackend} and puts them together.
164
+ #
165
+ # When {#test?} returns +false+, the poller will be started.
166
+ def apply
167
+ self.client ||= Client.new(to_hash)
168
+ self.cache ||= Cache.new(client, to_hash)
169
+ poller = Poller.new(cache, to_hash)
170
+ process_guard = ProcessGuard.new(cache, poller, to_hash)
171
+ I18n.backend = I18nBackend.new(cache)
172
+
173
+ if middleware && development?
174
+ middleware.use RequestSync, :cache => cache
175
+ end
176
+
177
+ @applied = true
178
+ logger.info "Client #{VERSION} ready"
179
+ logger.info "Environment Info: #{environment_info}"
180
+
181
+ unless test?
182
+ process_guard.start
183
+ end
184
+ end
185
+
186
+ def port
187
+ @port || default_port
188
+ end
189
+
190
+ # The protocol that should be used when generating URLs to CopyTuner.
191
+ # @return [String] +https+ if {#secure?} returns +true+, +http+ otherwise.
192
+ def protocol
193
+ if secure?
194
+ 'https'
195
+ else
196
+ 'http'
197
+ end
198
+ end
199
+
200
+ # For logging/debugging (internal).
201
+ # @return [String] a description of the environment in which this configuration was built.
202
+ def environment_info
203
+ parts = ["Ruby: #{RUBY_VERSION}", framework, "Env: #{environment_name}"]
204
+ parts.compact.map { |part| "[#{part}]" }.join(" ")
205
+ end
206
+
207
+ # Wraps the given logger in a PrefixedLogger. This way, CopyTunerClient
208
+ # log messages are recognizable.
209
+ # @param original_logger [Logger] the upstream logger to use, which must respond to the standard +Logger+ severity methods.
210
+ def logger=(original_logger)
211
+ @logger = PrefixedLogger.new("** [CopyTuner]", original_logger)
212
+ end
213
+
214
+ private
215
+
216
+ def default_port
217
+ if secure?
218
+ 443
219
+ else
220
+ 80
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,12 @@
1
+ module CopyTunerClient
2
+ # Raised when an error occurs while contacting the CopyTuner server. This is
3
+ # raised by {Client} and generally rescued by {Cache}. The application will
4
+ # not encounter this error. Polling will continue even if this error is raised.
5
+ class ConnectionError < StandardError
6
+ end
7
+
8
+ # Raised when the client is configured with an api key that the CopyTuner
9
+ # server does not recognize. Polling is aborted when this error is raised.
10
+ class InvalidApiKey < StandardError
11
+ end
12
+ end
@@ -0,0 +1,92 @@
1
+ require 'i18n'
2
+
3
+ module CopyTunerClient
4
+ # I18n implementation designed to synchronize with CopyTuner.
5
+ #
6
+ # Expects an object that acts like a Hash, responding to +[]+, +[]=+, and +keys+.
7
+ #
8
+ # This backend will be used as the default I18n backend when the client is
9
+ # configured, so you will not need to instantiate this class from the
10
+ # application. Instead, just use methods on the I18n class.
11
+ #
12
+ # This implementation will also load translations from locale files.
13
+ class I18nBackend
14
+ include I18n::Backend::Simple::Implementation
15
+
16
+ # Usually instantiated when {Configuration#apply} is invoked.
17
+ # @param cache [Cache] must act like a hash, returning and accept blurbs by key.
18
+ def initialize(cache)
19
+ @cache = cache
20
+ end
21
+
22
+ # Translates the given local and key. See the I18n API documentation for details.
23
+ #
24
+ # @return [Object] the translated key (usually a String)
25
+ def translate(locale, key, options = {})
26
+ content = super(locale, key, options.merge(:fallback => true))
27
+ if content.respond_to?(:html_safe)
28
+ content.html_safe
29
+ else
30
+ content
31
+ end
32
+ end
33
+
34
+ # Returns locales availabile for this CopyTuner project.
35
+ # @return [Array<String>] available locales
36
+ def available_locales
37
+ cached_locales = cache.keys.map { |key| key.split('.').first }
38
+ (cached_locales + super).uniq.map { |locale| locale.to_sym }
39
+ end
40
+
41
+ # Stores the given translations.
42
+ #
43
+ # Updates will be visible in the current process immediately, and will
44
+ # propagate to CopyTuner during the next flush.
45
+ #
46
+ # @param [String] locale the locale (ie "en") to store translations for
47
+ # @param [Hash] data nested key-value pairs to be added as blurbs
48
+ # @param [Hash] options unused part of the I18n API
49
+ def store_translations(locale, data, options = {})
50
+ super
51
+ store_item(locale, data)
52
+ end
53
+
54
+ private
55
+
56
+ def lookup(locale, key, scope = [], options = {})
57
+ parts = I18n.normalize_keys(locale, key, scope, options[:separator])
58
+ key_with_locale = parts.join('.')
59
+ content = cache[key_with_locale] || super
60
+ cache[key_with_locale] = "" if content.nil?
61
+ content
62
+ end
63
+
64
+ def store_item(locale, data, scope = [])
65
+ if data.respond_to?(:to_hash)
66
+ data.to_hash.each do |key, value|
67
+ store_item(locale, value, scope + [key])
68
+ end
69
+ elsif data.respond_to?(:to_str)
70
+ key = ([locale] + scope).join('.')
71
+ cache[key] = data.to_str
72
+ end
73
+ end
74
+
75
+ def load_translations(*filenames)
76
+ super
77
+ cache.wait_for_download
78
+ end
79
+
80
+ def default(locale, object, subject, options = {})
81
+ content = super(locale, object, subject, options)
82
+ if content.respond_to?(:to_str)
83
+ parts = I18n.normalize_keys(locale, object, options[:scope], options[:separator])
84
+ key = parts.join('.')
85
+ cache[key] = content.to_str
86
+ end
87
+ content
88
+ end
89
+
90
+ attr_reader :cache
91
+ end
92
+ end