em-twitter 0.1.0

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 (40) hide show
  1. data/.gemtest +0 -0
  2. data/.gitignore +40 -0
  3. data/.rspec +3 -0
  4. data/.simplecov +1 -0
  5. data/.travis.yml +8 -0
  6. data/.yardopts +3 -0
  7. data/Gemfile +7 -0
  8. data/Guardfile +6 -0
  9. data/LICENSE.md +20 -0
  10. data/README.md +117 -0
  11. data/Rakefile +15 -0
  12. data/em-twitter.gemspec +31 -0
  13. data/examples/stream.rb +61 -0
  14. data/lib/em-twitter.rb +35 -0
  15. data/lib/em-twitter/client.rb +111 -0
  16. data/lib/em-twitter/connection.rb +273 -0
  17. data/lib/em-twitter/decoders/base_decoder.rb +11 -0
  18. data/lib/em-twitter/decoders/gzip_decoder.rb +14 -0
  19. data/lib/em-twitter/proxy.rb +25 -0
  20. data/lib/em-twitter/reconnectors/application_failure.rb +50 -0
  21. data/lib/em-twitter/reconnectors/network_failure.rb +51 -0
  22. data/lib/em-twitter/request.rb +126 -0
  23. data/lib/em-twitter/response.rb +48 -0
  24. data/lib/em-twitter/version.rb +5 -0
  25. data/lib/em_twitter.rb +1 -0
  26. data/smoke.rb +66 -0
  27. data/spec/em-twitter/client_spec.rb +55 -0
  28. data/spec/em-twitter/connection_error_handling_spec.rb +12 -0
  29. data/spec/em-twitter/connection_reconnect_spec.rb +139 -0
  30. data/spec/em-twitter/connection_spec.rb +148 -0
  31. data/spec/em-twitter/decoders/base_decoder_spec.rb +15 -0
  32. data/spec/em-twitter/decoders/gzip_decoder_spec.rb +20 -0
  33. data/spec/em-twitter/proxy_spec.rb +23 -0
  34. data/spec/em-twitter/reconnectors/application_failure_spec.rb +74 -0
  35. data/spec/em-twitter/reconnectors/network_failure_spec.rb +80 -0
  36. data/spec/em-twitter/request_spec.rb +77 -0
  37. data/spec/em-twitter/response_spec.rb +89 -0
  38. data/spec/em_twitter_spec.rb +19 -0
  39. data/spec/spec_helper.rb +68 -0
  40. metadata +207 -0
@@ -0,0 +1,273 @@
1
+ require 'eventmachine'
2
+ require 'em/buftok'
3
+ require 'uri'
4
+ require 'http/parser'
5
+ require 'openssl'
6
+
7
+ require 'em-twitter/proxy'
8
+ require 'em-twitter/request'
9
+ require 'em-twitter/response'
10
+ require 'em-twitter/decoders/base_decoder'
11
+ require 'em-twitter/decoders/gzip_decoder'
12
+
13
+ require 'em-twitter/reconnectors/application_failure'
14
+ require 'em-twitter/reconnectors/network_failure'
15
+
16
+ module EventMachine
17
+ module Twitter
18
+ class Connection < EM::Connection
19
+
20
+ MAX_LINE_LENGTH = 1024*1024
21
+
22
+ attr_reader :host, :port, :client, :options, :headers
23
+ attr_accessor :reconnector
24
+
25
+ def initialize(client, host, port)
26
+ @client = client
27
+ @host = host
28
+ @port = port
29
+ @options = @client.options
30
+ @on_inited_callback = @options.delete(:on_inited)
31
+
32
+ if verify_peer?
33
+ @certificate_store = OpenSSL::X509::Store.new
34
+ @certificate_store.add_file(@options[:ssl][:cert_chain_file])
35
+ end
36
+ end
37
+
38
+ # Called after the connection to the server is completed. Initiates a
39
+ #
40
+ def connection_completed
41
+ start_tls(@options[:ssl]) if ssl?
42
+
43
+ reset_connection
44
+
45
+ @request = Request.new(@options)
46
+ send_data(@request)
47
+ end
48
+
49
+ def post_init
50
+ @headers = {}
51
+ @reconnector = EM::Twitter::Reconnectors::NetworkFailure.new
52
+
53
+ invoke_callback(@on_inited_callback)
54
+ set_comm_inactivity_timeout(@options[:timeout]) if @options[:timeout] > 0
55
+ end
56
+
57
+
58
+ # Receives responses from the server and passes them on to the HttpParser
59
+ def receive_data(data)
60
+ @parser << data
61
+ end
62
+
63
+ # Close the connection gracefully, without reconnecting
64
+ def stop
65
+ @gracefully_closed = true
66
+ close_connection
67
+ end
68
+
69
+ # Immediately reconnects the connection
70
+ def immediate_reconnect
71
+ @immediate_reconnect = true
72
+ @gracefully_closed = false
73
+ close_connection
74
+ end
75
+
76
+ # Called when a connection is disconnected
77
+ def unbind
78
+ schedule_reconnect if @options[:auto_reconnect] && !gracefully_closed?
79
+ invoke_callback(@client.close_callback)
80
+ end
81
+
82
+ # Returns a status of the connection, if no response was ever received from
83
+ # the server, then we assume a network failure.
84
+ def network_failure?
85
+ @response_code == 0
86
+ end
87
+
88
+ # Returns the current state of the gracefully_closed flag
89
+ # gracefully_closed is set to true when the connection is
90
+ # explicitly stopped using the stop method
91
+ def gracefully_closed?
92
+ @gracefully_closed
93
+ end
94
+
95
+ # Returns the current state of the immediate_reconnect flag
96
+ # immediate_reconnect is true when the immediate_reconnect
97
+ # method is invoked on the connection
98
+ def immediate_reconnect?
99
+ @immediate_reconnect
100
+ end
101
+
102
+ def update(options={})
103
+ @options.merge!(options)
104
+ immediate_reconnect
105
+ end
106
+
107
+ protected
108
+
109
+ def handle_stream(data)
110
+ @last_response << @decoder.decode(data)
111
+
112
+ if @last_response.complete?
113
+ invoke_callback(@client.each_item_callback, @last_response.body)
114
+ end
115
+ end
116
+
117
+ # HttpParser implementation, invoked after response headers are received
118
+ def on_headers_complete(headers)
119
+ @response_code = @parser.status_code
120
+ @headers = headers
121
+
122
+ @decoder = BaseDecoder.new
123
+
124
+ # TODO: Complete gzip support
125
+ # detect gzip encoding and use a decoder for response bodies
126
+ # gzip needs to be detected with the Content-Encoding header
127
+ # @decoder = if gzip?
128
+ # GzipDecoder.new
129
+ # else
130
+ # BaseDecoder.new
131
+ # end
132
+
133
+ # since we got a response use the application failure reconnector
134
+ # to handle redirects
135
+ @reconnector = EM::Twitter::Reconnectors::ApplicationFailure.new
136
+
137
+ # everything below here is error handling so return if we got a 200
138
+ return if @response_code == 200
139
+
140
+ case @response_code
141
+ when 401 then invoke_callback(@client.unauthorized_callback)
142
+ when 403 then invoke_callback(@client.forbidden_callback)
143
+ when 404 then invoke_callback(@client.not_found_callback)
144
+ when 406 then invoke_callback(@client.not_acceptable_callback)
145
+ when 413 then invoke_callback(@client.too_long_callback)
146
+ when 416 then invoke_callback(@client.range_unacceptable_callback)
147
+ when 420 then invoke_callback(@client.enhance_your_calm_callback)
148
+ else
149
+ msg = "Unhandled status code: #{@response_code}."
150
+ invoke_callback(@client.error_callback, msg)
151
+ end
152
+ end
153
+
154
+ # HttpParser implementation, invoked when a body is received
155
+ def on_body(data)
156
+ begin
157
+ @buffer.extract(data).each do |line|
158
+ handle_stream(line)
159
+ end
160
+ @last_response.reset if @last_response.complete?
161
+ rescue => e
162
+ msg = "#{e.class}: " + [e.message, e.backtrace].flatten.join("\n\t")
163
+ invoke_callback(@client.error_callback, msg)
164
+
165
+ close_connection
166
+ return
167
+ end
168
+ end
169
+
170
+ # It's important that we try to not add a certificate to the store that's
171
+ # already in the store, because OpenSSL::X509::Store will raise an exception.
172
+ def ssl_verify_peer(cert_string)
173
+ cert = nil
174
+ begin
175
+ cert = OpenSSL::X509::Certificate.new(cert_string)
176
+ rescue OpenSSL::X509::CertificateError
177
+ return false
178
+ end
179
+
180
+ @last_seen_cert = cert
181
+
182
+ if @certificate_store.verify(@last_seen_cert)
183
+ begin
184
+ @certificate_store.add_cert(@last_seen_cert)
185
+ rescue OpenSSL::X509::StoreError => e
186
+ raise e unless e.message == 'cert already in hash table'
187
+ end
188
+ true
189
+ else
190
+ raise OpenSSL::OpenSSLError.new("unable to verify the server certificate of #{@client.host}")
191
+ false
192
+ end
193
+ end
194
+
195
+ def ssl_handshake_completed
196
+ unless OpenSSL::SSL.verify_certificate_identity(@last_seen_cert, @client.host)
197
+ fail OpenSSL::OpenSSLError.new("the hostname '#{@client.host}' does not match the server certificate")
198
+ false
199
+ else
200
+ true
201
+ end
202
+ end
203
+
204
+ def ssl?
205
+ @options[:ssl]
206
+ end
207
+
208
+ def gzip?
209
+ @headers['Content-Encoding'] && @headers['Content-Encoding'] == 'gzip'
210
+ end
211
+
212
+ def verify_peer?
213
+ ssl? && @options[:ssl][:verify_peer]
214
+ end
215
+
216
+ # Handles reconnection to the server when a disconnect occurs. By using a
217
+ # reconnector, it will gradually increase the time between reconnects
218
+ # per Twitter's reconnection guidelines.
219
+ def schedule_reconnect
220
+ if gracefully_closed?
221
+ reconnect_after(0)
222
+ @gracefully_closed = false
223
+ return
224
+ end
225
+
226
+ begin
227
+ @reconnector.increment do |timeout|
228
+ reconnect_after(timeout)
229
+ end
230
+ rescue ReconnectLimitError => e
231
+ invoke_callback(@client.max_reconnects_callback,
232
+ @reconnector.reconnect_timeout,
233
+ @reconnector.reconnect_count)
234
+ end
235
+ end
236
+
237
+ # Performs the reconnection after x seconds have passed.
238
+ # Reconnection is performed immediately if the argument passed
239
+ # is zero.
240
+ #
241
+ # Otherwise it will create an EM::Timer that will reconnect
242
+ def reconnect_after(reconnect_timeout)
243
+ invoke_callback(@client.reconnect_callback,
244
+ @reconnector.reconnect_timeout,
245
+ @reconnector.reconnect_count)
246
+
247
+ if reconnect_timeout.zero?
248
+ reconnect(@host, @port)
249
+ else
250
+ EM::Timer.new(reconnect_timeout) { reconnect(@host, @port) }
251
+ end
252
+ end
253
+
254
+ # A utility method used to invoke callback methods against the Client
255
+ def invoke_callback(callback, *args)
256
+ callback.call(*args) if callback
257
+ end
258
+
259
+ # Resets the internals of the connection on initial connection and
260
+ # on reconnections. Clears the response buffer and resets internal state
261
+ def reset_connection
262
+ @buffer = BufferedTokenizer.new("\r", MAX_LINE_LENGTH)
263
+ @parser = Http::Parser.new(self)
264
+ @last_response = Response.new
265
+ @response_code = 0
266
+
267
+ @gracefully_closed = false
268
+ @immediate_reconnect = false
269
+ end
270
+
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,11 @@
1
+ module EventMachine
2
+ module Twitter
3
+ class BaseDecoder
4
+
5
+ def decode(str)
6
+ str
7
+ end
8
+
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ require 'zlib'
2
+ require 'stringio'
3
+
4
+ module EventMachine
5
+ module Twitter
6
+ class GzipDecoder
7
+
8
+ def decode(str)
9
+ Zlib::GzipReader.new(StringIO.new(str.to_s)).read
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ module EventMachine
2
+ module Twitter
3
+ class Proxy
4
+
5
+ attr_reader :user, :password, :uri
6
+
7
+ def initialize(options = {})
8
+ @user = options.delete(:user)
9
+ @password = options.delete(:password)
10
+ @uri = options.delete(:uri)
11
+ end
12
+
13
+ def header
14
+ ["#{@user}:#{@password}"].pack('m').delete("\r\n") if credentials?
15
+ end
16
+
17
+ private
18
+
19
+ def credentials?
20
+ @user && @password
21
+ end
22
+
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,50 @@
1
+ module EventMachine
2
+ module Twitter
3
+ module Reconnectors
4
+ class ApplicationFailure
5
+
6
+ START = 10
7
+ INCREMENTOR = 2
8
+
9
+ MAX_RECONNECTS = 10
10
+ DEFAULT_RECONNECT = 0
11
+ MAX_TIMEOUT = 320
12
+
13
+ attr_reader :reconnect_count
14
+ attr_writer :reconnect_timeout
15
+
16
+ def initialize(options = {})
17
+ @reconnect_count = options.delete(:reconnect_count) || DEFAULT_RECONNECT
18
+ @reconnect_timeout = options.delete(:reconnect_timeout) || START
19
+ end
20
+
21
+ def reconnect_timeout
22
+ @reconnect_timeout
23
+ end
24
+
25
+ def increment
26
+ @reconnect_count += 1
27
+ @reconnect_timeout *= INCREMENTOR
28
+
29
+ if maximum_reconnects?
30
+ raise EM::Twitter::ReconnectLimitError.new("#{@reconnect_count} Reconnects")
31
+ end
32
+
33
+ yield @reconnect_timeout if block_given?
34
+ end
35
+
36
+ def reset
37
+ @reconnect_timeout = START
38
+ @reconnect_count = DEFAULT_RECONNECT
39
+ end
40
+
41
+ private
42
+
43
+ def maximum_reconnects?
44
+ @reconnect_count > MAX_RECONNECTS || @reconnect_timeout > MAX_TIMEOUT
45
+ end
46
+
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ module EventMachine
2
+ module Twitter
3
+ module Reconnectors
4
+ class NetworkFailure
5
+
6
+ START = 0.25
7
+ INCREMENTOR = 0.25
8
+ MAX = 16
9
+
10
+ MAX_RECONNECTS = 10
11
+ DEFAULT_RECONNECT = 0
12
+ MAX_TIMEOUT = 320
13
+
14
+ attr_reader :reconnect_count
15
+ attr_writer :reconnect_timeout
16
+
17
+ def initialize(options = {})
18
+ @reconnect_timeout = options.delete(:reconnect_timeout) || START
19
+ @reconnect_count = options.delete(:reconnect_count) || DEFAULT_RECONNECT
20
+ end
21
+
22
+ def reconnect_timeout
23
+ [@reconnect_timeout, MAX].min
24
+ end
25
+
26
+ def increment
27
+ @reconnect_count += 1
28
+ @reconnect_timeout += INCREMENTOR
29
+
30
+ if maximum_reconnects?
31
+ raise EM::Twitter::ReconnectLimitError.new("#{@reconnect_count} Reconnects")
32
+ end
33
+
34
+ yield @reconnect_timeout if block_given?
35
+ end
36
+
37
+ def reset
38
+ @reconnect_timeout = START
39
+ @reconnect_count = DEFAULT_RECONNECT
40
+ end
41
+
42
+ private
43
+
44
+ def maximum_reconnects?
45
+ @reconnect_count > MAX_RECONNECTS || @reconnect_timeout > MAX_TIMEOUT
46
+ end
47
+
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,126 @@
1
+ require 'uri'
2
+ require 'simple_oauth'
3
+
4
+ module EventMachine
5
+ module Twitter
6
+ class Request
7
+
8
+ attr_reader :proxy, :options
9
+
10
+ def initialize(options = {})
11
+ @options = options
12
+ @proxy = Proxy.new(@options.delete(:proxy)) if @options[:proxy]
13
+ end
14
+
15
+ def to_s
16
+ content = query
17
+
18
+ data = []
19
+ data << "#{request_method} #{request_uri} HTTP/1.1"
20
+ data << "Host: #{@options[:host]}"
21
+
22
+ data << 'Accept: */*'
23
+
24
+ # TODO: Complete gzip support
25
+ # if gzip?
26
+ # data << 'Connection: Keep-Alive'
27
+ # data << 'Accept-Encoding: deflate'
28
+ # else
29
+ # data << 'Accept: */*'
30
+ # end
31
+
32
+ data << "User-Agent: #{@options[:user_agent]}" if @options[:user_agent]
33
+ if put_or_post?
34
+ data << "Content-type: #{@options[:content_type]}"
35
+ data << "Content-length: #{content.length}"
36
+ end
37
+ data << "Authorization: #{oauth_header}"
38
+ data << "Proxy-Authorization: Basic #{proxy.header}" if proxy?
39
+
40
+ @options[:headers].each do |name, value|
41
+ data << "#{name}: #{value}"
42
+ end
43
+
44
+ data << "\r\n"
45
+ data = data.join("\r\n")
46
+ data << content if post? || put?
47
+ data
48
+ end
49
+
50
+ def proxy?
51
+ @proxy
52
+ end
53
+
54
+ private
55
+
56
+ def get?
57
+ request_method == 'GET'
58
+ end
59
+
60
+ def post?
61
+ request_method == 'POST'
62
+ end
63
+
64
+ def put?
65
+ request_method == 'PUT'
66
+ end
67
+
68
+ def put_or_post?
69
+ put? || post?
70
+ end
71
+
72
+ def gzip?
73
+ @options[:encoding] && @options[:encoding] == 'gzip'
74
+ end
75
+
76
+ def request_method
77
+ @options[:method].to_s.upcase
78
+ end
79
+
80
+ def params
81
+ flat = {}
82
+ @options[:params].each do |param, val|
83
+ next if val.to_s.empty? || (val.respond_to?(:empty?) && val.empty?)
84
+ val = val.join(",") if val.respond_to?(:join)
85
+ flat[param.to_s] = val.to_s
86
+ end
87
+ flat
88
+ end
89
+
90
+ def query
91
+ params.map do |param, value|
92
+ [param, SimpleOAuth::Header.encode(value)].join("=")
93
+ end.sort.join("&")
94
+ end
95
+
96
+ def oauth_header
97
+ SimpleOAuth::Header.new(@options[:method], full_uri, params, @options[:oauth])
98
+ end
99
+
100
+ def proxy_uri
101
+ "#{uri_base}:#{@options[:port]}#{path}"
102
+ end
103
+
104
+ def request_uri
105
+ proxy? ? proxy_uri : path
106
+ end
107
+
108
+ def path
109
+ get? ? "#{@options[:path]}?#{query}" : @options[:path]
110
+ end
111
+
112
+ def uri_base
113
+ "#{protocol}://#{@options[:host]}"
114
+ end
115
+
116
+ def protocol
117
+ @options[:ssl] ? 'https' : 'http'
118
+ end
119
+
120
+ def full_uri
121
+ proxy? ? proxy_uri : "#{uri_base}#{request_uri}"
122
+ end
123
+
124
+ end
125
+ end
126
+ end