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