em-twitter 0.1.0

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