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,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