em-twitter 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gemtest +0 -0
- data/.gitignore +40 -0
- data/.rspec +3 -0
- data/.simplecov +1 -0
- data/.travis.yml +8 -0
- data/.yardopts +3 -0
- data/Gemfile +7 -0
- data/Guardfile +6 -0
- data/LICENSE.md +20 -0
- data/README.md +117 -0
- data/Rakefile +15 -0
- data/em-twitter.gemspec +31 -0
- data/examples/stream.rb +61 -0
- data/lib/em-twitter.rb +35 -0
- data/lib/em-twitter/client.rb +111 -0
- data/lib/em-twitter/connection.rb +273 -0
- data/lib/em-twitter/decoders/base_decoder.rb +11 -0
- data/lib/em-twitter/decoders/gzip_decoder.rb +14 -0
- data/lib/em-twitter/proxy.rb +25 -0
- data/lib/em-twitter/reconnectors/application_failure.rb +50 -0
- data/lib/em-twitter/reconnectors/network_failure.rb +51 -0
- data/lib/em-twitter/request.rb +126 -0
- data/lib/em-twitter/response.rb +48 -0
- data/lib/em-twitter/version.rb +5 -0
- data/lib/em_twitter.rb +1 -0
- data/smoke.rb +66 -0
- data/spec/em-twitter/client_spec.rb +55 -0
- data/spec/em-twitter/connection_error_handling_spec.rb +12 -0
- data/spec/em-twitter/connection_reconnect_spec.rb +139 -0
- data/spec/em-twitter/connection_spec.rb +148 -0
- data/spec/em-twitter/decoders/base_decoder_spec.rb +15 -0
- data/spec/em-twitter/decoders/gzip_decoder_spec.rb +20 -0
- data/spec/em-twitter/proxy_spec.rb +23 -0
- data/spec/em-twitter/reconnectors/application_failure_spec.rb +74 -0
- data/spec/em-twitter/reconnectors/network_failure_spec.rb +80 -0
- data/spec/em-twitter/request_spec.rb +77 -0
- data/spec/em-twitter/response_spec.rb +89 -0
- data/spec/em_twitter_spec.rb +19 -0
- data/spec/spec_helper.rb +68 -0
- 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,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
|