cantino-twitter-stream 0.1.15

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5ca1d5b33fa41468caf5c6019d5f8841db2d2e37
4
+ data.tar.gz: 186a3a7bd214aea7b951da75cb6737ceb93f8c3f
5
+ SHA512:
6
+ metadata.gz: 1566c82d837ee63c4ead22897851c1a1d33cae3fed16c6b96cfe43549e0733c2c1d67001ed8419afd985c91212ce5ed86c3ed38476aafc83a9166f69d386c5a7
7
+ data.tar.gz: 894d5f39ee242f3e32a69c12399e0464148400fc69c01bb836cd65b0c99873b63212db547801b4edc8de8c88c11a5c1bb68d52fa1391bb971f847913cc78dc2a
File without changes
@@ -0,0 +1,10 @@
1
+ *.sw?
2
+ .rake_tasks~
3
+ .DS_Store
4
+ coverage
5
+ rdoc
6
+ pkg
7
+ Gemfile.lock
8
+ coverage/*
9
+ .yardoc/*
10
+ doc/*
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --colour
2
+ --format doc
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source :rubygems
2
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Vladimir Kolesnikov, twitter-stream
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,47 @@
1
+ # twitter-stream
2
+
3
+ Simple Ruby client library for [twitter streaming API](http://apiwiki.twitter.com/Streaming-API-Documentation).
4
+ Uses [EventMachine](http://rubyeventmachine.com/) for connection handling. Adheres to twitter's [reconnection guidline](https://dev.twitter.com/docs/streaming-api/concepts#connecting).
5
+
6
+ JSON format only.
7
+
8
+ ## Install
9
+
10
+ sudo gem install twitter-stream -s http://gemcutter.org
11
+
12
+ ## Usage
13
+
14
+ require 'rubygems'
15
+ require 'twitter/json_stream'
16
+
17
+ EventMachine::run {
18
+ stream = Twitter::JSONStream.connect(
19
+ :path => '/1/statuses/filter.json?track=football',
20
+ :auth => 'LOGIN:PASSWORD'
21
+ )
22
+
23
+ stream.each_item do |item|
24
+ # Do someting with unparsed JSON item.
25
+ end
26
+
27
+ stream.on_error do |message|
28
+ # No need to worry here. It might be an issue with Twitter.
29
+ # Log message for future reference. JSONStream will try to reconnect after a timeout.
30
+ end
31
+
32
+ stream.on_max_reconnects do |timeout, retries|
33
+ # Something is wrong on your side. Send yourself an email.
34
+ end
35
+
36
+ stream.on_no_data do
37
+ # Twitter has stopped sending any data on the currently active
38
+ # connection, reconnecting is probably in order
39
+ end
40
+ }
41
+
42
+
43
+ ## Examples
44
+
45
+ Open examples/reader.rb. Replace LOGIN:PASSWORD with your real twitter login and password. And
46
+ ruby examples/reader.rb
47
+
@@ -0,0 +1,12 @@
1
+ require 'rubygems'
2
+
3
+ require 'bundler'
4
+ Bundler::GemHelper.install_tasks
5
+
6
+ gem 'rspec', '>= 2.5.0'
7
+ require 'rspec/core/rake_task'
8
+
9
+ desc "Run all specs"
10
+ RSpec::Core::RakeTask.new(:spec)
11
+ task :default => :spec
12
+ task :test => :spec
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.14
@@ -0,0 +1,40 @@
1
+ require 'rubygems'
2
+ lib_path = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+ $LOAD_PATH.unshift lib_path unless $LOAD_PATH.include?(lib_path)
4
+
5
+ require 'twitter/json_stream'
6
+
7
+ EventMachine::run {
8
+ stream = Twitter::JSONStream.connect(
9
+ :path => '/1/statuses/filter.json',
10
+ :auth => 'LOGIN:PASSWORD',
11
+ :method => 'POST',
12
+ :content => 'track=basketball,football,baseball,footy,soccer'
13
+ )
14
+
15
+ stream.each_item do |item|
16
+ $stdout.print "item: #{item}\n"
17
+ $stdout.flush
18
+ end
19
+
20
+ stream.on_error do |message|
21
+ $stdout.print "error: #{message}\n"
22
+ $stdout.flush
23
+ end
24
+
25
+ stream.on_reconnect do |timeout, retries|
26
+ $stdout.print "reconnecting in: #{timeout} seconds\n"
27
+ $stdout.flush
28
+ end
29
+
30
+ stream.on_max_reconnects do |timeout, retries|
31
+ $stdout.print "Failed after #{retries} failed reconnects\n"
32
+ $stdout.flush
33
+ end
34
+
35
+ trap('TERM') {
36
+ stream.stop
37
+ EventMachine.stop if EventMachine.reactor_running?
38
+ }
39
+ }
40
+ puts "The event loop has ended"
@@ -0,0 +1,4 @@
1
+ {"text":"Just wanted the world to know what our theologically deep pastor @bprentice has for a desktop background. http://yfrog.com/1rl4ij","favorited":false,"in_reply_to_user_id":null,"in_reply_to_screen_name":null,"source":"<a href=\"http://www.atebits.com/\" rel=\"nofollow\">Tweetie</a>","truncated":false,"created_at":"Thu Oct 08 19:34:09 +0000 2009","geo":null,"user":{"geo_enabled":false,"profile_text_color":"5b5252","location":"Stillwater, Oklahoma","statuses_count":122,"followers_count":70,"profile_link_color":"220099","description":"Taking an unchanging Savior to a changing world! Eagle Heights in Stillwater, Oklahoma.","following":null,"friends_count":136,"profile_sidebar_fill_color":"f37a20","url":"http://www.eagleheights.com","profile_image_url":"http://a3.twimg.com/profile_images/249941843/online_logo_normal.jpg","verified":false,"notifications":null,"favourites_count":0,"profile_sidebar_border_color":"c5f109","protected":false,"screen_name":"eagleheights","profile_background_tile":false,"profile_background_image_url":"http://a1.twimg.com/profile_background_images/5935314/EHBC_LOGO.jpg","created_at":"Tue Mar 17 14:52:04 +0000 2009","name":"Eagle Heights","time_zone":"Central Time (US & Canada)","id":24892440,"utc_offset":-21600,"profile_background_color":"3d3d3d"},"id":4714982187,"in_reply_to_status_id":null}
2
+ {"text":"I finally took a good pic of our resident BobCat @ the JakeCruise Ranch: http://yfrog.com/769vij","favorited":false,"in_reply_to_user_id":null,"in_reply_to_screen_name":null,"source":"<a href=\"http://www.atebits.com/\" rel=\"nofollow\">Tweetie</a>","truncated":false,"created_at":"Thu Oct 08 19:34:12 +0000 2009","geo":null,"user":{"geo_enabled":false,"profile_text_color":"141318","location":"West Hollywood, CA","statuses_count":265,"followers_count":80,"profile_link_color":"DA4425","description":"Gay Programmer in SoCal","following":null,"friends_count":37,"profile_sidebar_fill_color":"5DD2F4","url":"http://www.kelvo.com/","profile_image_url":"http://a1.twimg.com/profile_images/197950488/kelvis_green_current_normal.jpeg","verified":false,"notifications":null,"favourites_count":1,"profile_sidebar_border_color":"1F6926","protected":false,"screen_name":"KelvisWeHo","profile_background_tile":false,"profile_background_image_url":"http://a1.twimg.com/profile_background_images/9898116/thaneeya3.jpg","created_at":"Fri Apr 17 18:44:57 +0000 2009","name":"Kelvis Del Rio","time_zone":"Pacific Time (US & Canada)","id":32517583,"utc_offset":-28800,"profile_background_color":"cccccc"},"id":4714983168,"in_reply_to_status_id":null}
3
+ {"text":"Thursdays are long. But at least we're allowed a cheat sheet for stats. http://yfrog.com/9gjc7j","favorited":false,"in_reply_to_user_id":null,"in_reply_to_screen_name":null,"source":"<a href=\"http://twitterrific.com\" rel=\"nofollow\">Twitterrific</a>","truncated":false,"created_at":"Thu Oct 08 19:34:21 +0000 2009","geo":null,"user":{"geo_enabled":false,"profile_text_color":"663B12","location":"Calgary ","statuses_count":68,"followers_count":10,"profile_link_color":"1F98C7","description":"pure. love. art. music. ","following":null,"friends_count":22,"profile_sidebar_fill_color":"DAECF4","url":null,"profile_image_url":"http://a3.twimg.com/profile_images/391882135/Photo_550_normal.jpg","verified":false,"notifications":null,"favourites_count":5,"profile_sidebar_border_color":"C6E2EE","protected":false,"screen_name":"KarinRudmik","profile_background_tile":false,"profile_background_image_url":"http://a3.twimg.com/profile_background_images/33520483/shimmering__by_AnBystrowska.jpg","created_at":"Wed Jul 29 02:37:13 +0000 2009","name":"Karin Rudmik","time_zone":"Mountain Time (US & Canada)","id":61091098,"utc_offset":-25200,"profile_background_color":"C6E2EE"},"id":4714986148,"in_reply_to_status_id":null}
4
+ {"text":"acabando de almorzar, les comparto mi ultima creacion: http://yfrog.com/5mi94j pollo a la maracuya, con verduras sofritas ^^","favorited":false,"in_reply_to_user_id":null,"in_reply_to_screen_name":null,"source":"<a href=\"http://www.seesmic.com/\" rel=\"nofollow\">Seesmic</a>","truncated":false,"created_at":"Thu Oct 08 19:34:28 +0000 2009","geo":null,"user":{"geo_enabled":false,"profile_text_color":"3C3940","location":"Cartagena de Indias","statuses_count":1016,"followers_count":190,"profile_link_color":"0099B9","description":"Cartagenero extremadamente consentido, flojo y amante del anime, el manga, la cocina y los mmorpgs. pdt: y de los postres por supuesto!!!","following":null,"friends_count":253,"profile_sidebar_fill_color":"95E8EC","url":"http://www.flickr.com/photos/lobitokun/","profile_image_url":"http://a1.twimg.com/profile_images/451679242/shippo_normal.jpg","verified":false,"notifications":null,"favourites_count":9,"profile_sidebar_border_color":"5ED4DC","protected":false,"screen_name":"lobitokun","profile_background_tile":false,"profile_background_image_url":"http://a3.twimg.com/profile_background_images/24664583/hideki1.jpg","created_at":"Fri Jul 17 15:32:14 +0000 2009","name":"emiro gomez beltran","time_zone":"Bogota","id":57672295,"utc_offset":-18000,"profile_background_color":"0099B9"},"id":4714988998,"in_reply_to_status_id":null}
@@ -0,0 +1,366 @@
1
+ require 'eventmachine'
2
+ require 'em/buftok'
3
+ require 'uri'
4
+ require 'simple_oauth'
5
+ require 'http/parser'
6
+
7
+ module Twitter
8
+ class JSONStream < EventMachine::Connection
9
+ MAX_LINE_LENGTH = 1024*1024
10
+
11
+ # network failure reconnections
12
+ NF_RECONNECT_START = 0.25
13
+ NF_RECONNECT_ADD = 0.25
14
+ NF_RECONNECT_MAX = 16
15
+
16
+ # app failure reconnections
17
+ AF_RECONNECT_START = 10
18
+ AF_RECONNECT_MUL = 2
19
+
20
+ RECONNECT_MAX = 320
21
+ RETRIES_MAX = 10
22
+
23
+ NO_DATA_TIMEOUT = 90
24
+
25
+ DEFAULT_OPTIONS = {
26
+ :method => 'GET',
27
+ :path => '/',
28
+ :content_type => "application/x-www-form-urlencoded",
29
+ :content => '',
30
+ :path => '/1/statuses/filter.json',
31
+ :host => 'stream.twitter.com',
32
+ :port => 443,
33
+ :ssl => true,
34
+ :user_agent => 'TwitterStream',
35
+ :timeout => 0,
36
+ :proxy => ENV['HTTP_PROXY'],
37
+ :auth => nil,
38
+ :oauth => {},
39
+ :filters => [],
40
+ :params => {},
41
+ :auto_reconnect => true
42
+ }
43
+
44
+ attr_accessor :code
45
+ attr_accessor :headers
46
+ attr_accessor :nf_last_reconnect
47
+ attr_accessor :af_last_reconnect
48
+ attr_accessor :reconnect_retries
49
+ attr_accessor :last_data_received_at
50
+ attr_accessor :proxy
51
+
52
+ def self.connect options = {}
53
+ options[:port] = 443 if options[:ssl] && !options.has_key?(:port)
54
+ options = DEFAULT_OPTIONS.merge(options)
55
+
56
+ host = options[:host]
57
+ port = options[:port]
58
+
59
+ if options[:proxy]
60
+ proxy_uri = URI.parse(options[:proxy])
61
+ host = proxy_uri.host
62
+ port = proxy_uri.port
63
+ end
64
+
65
+ connection = EventMachine.connect host, port, self, options
66
+ connection
67
+ end
68
+
69
+ def initialize options = {}
70
+ @options = DEFAULT_OPTIONS.merge(options) # merge in case initialize called directly
71
+ @gracefully_closed = false
72
+ @nf_last_reconnect = nil
73
+ @af_last_reconnect = nil
74
+ @reconnect_retries = 0
75
+ @immediate_reconnect = false
76
+ @on_inited_callback = options.delete(:on_inited)
77
+ @proxy = URI.parse(options[:proxy]) if options[:proxy]
78
+ @last_data_received_at = nil
79
+ end
80
+
81
+ def each_item &block
82
+ @each_item_callback = block
83
+ end
84
+
85
+ def on_error &block
86
+ @error_callback = block
87
+ end
88
+
89
+ def on_reconnect &block
90
+ @reconnect_callback = block
91
+ end
92
+
93
+ # Called when no data has been received for NO_DATA_TIMEOUT seconds.
94
+ # Reconnecting is probably in order as per the Twitter recommendations
95
+ def on_no_data &block
96
+ @no_data_callback = block
97
+ end
98
+
99
+ def on_max_reconnects &block
100
+ @max_reconnects_callback = block
101
+ end
102
+
103
+ def on_close &block
104
+ @close_callback = block
105
+ end
106
+
107
+ def stop
108
+ @gracefully_closed = true
109
+ close_connection
110
+ end
111
+
112
+ def immediate_reconnect
113
+ @immediate_reconnect = true
114
+ @gracefully_closed = false
115
+ close_connection
116
+ end
117
+
118
+ def unbind
119
+ if @state == :stream && !@buffer.empty?
120
+ parse_stream_line(@buffer.flush)
121
+ end
122
+ schedule_reconnect if @options[:auto_reconnect] && !@gracefully_closed
123
+ @close_callback.call if @close_callback
124
+ @state = :init
125
+ end
126
+
127
+ # Receives raw data from the HTTP connection and pushes it into the
128
+ # HTTP parser which then drives subsequent callbacks.
129
+ def receive_data(data)
130
+ @last_data_received_at = Time.now
131
+ @parser << data
132
+ end
133
+
134
+ def connection_completed
135
+ start_tls if @options[:ssl]
136
+ send_request
137
+ end
138
+
139
+ def post_init
140
+ reset_state
141
+ @on_inited_callback.call if @on_inited_callback
142
+ @reconnect_timer = EventMachine.add_periodic_timer(5) do
143
+ if @gracefully_closed
144
+ @reconnect_timer.cancel
145
+ elsif @last_data_received_at && Time.now - @last_data_received_at > NO_DATA_TIMEOUT
146
+ no_data
147
+ end
148
+ end
149
+ end
150
+
151
+ protected
152
+ def no_data
153
+ @no_data_callback.call if @no_data_callback
154
+ end
155
+
156
+ def schedule_reconnect
157
+ timeout = reconnect_timeout
158
+ @reconnect_retries += 1
159
+ if timeout <= RECONNECT_MAX && @reconnect_retries <= RETRIES_MAX
160
+ reconnect_after(timeout)
161
+ else
162
+ @max_reconnects_callback.call(timeout, @reconnect_retries) if @max_reconnects_callback
163
+ end
164
+ end
165
+
166
+ def reconnect_after timeout
167
+ @reconnect_callback.call(timeout, @reconnect_retries) if @reconnect_callback
168
+
169
+ if timeout == 0
170
+ reconnect @options[:host], @options[:port]
171
+ else
172
+ EventMachine.add_timer(timeout) do
173
+ reconnect @options[:host], @options[:port]
174
+ end
175
+ end
176
+ end
177
+
178
+ def reconnect_timeout
179
+ if @immediate_reconnect
180
+ @immediate_reconnect = false
181
+ return 0
182
+ end
183
+
184
+ if (@code == 0) # network failure
185
+ if @nf_last_reconnect
186
+ @nf_last_reconnect += NF_RECONNECT_ADD
187
+ else
188
+ @nf_last_reconnect = NF_RECONNECT_START
189
+ end
190
+ [@nf_last_reconnect,NF_RECONNECT_MAX].min
191
+ else
192
+ if @af_last_reconnect
193
+ @af_last_reconnect *= AF_RECONNECT_MUL
194
+ else
195
+ @af_last_reconnect = AF_RECONNECT_START
196
+ end
197
+ @af_last_reconnect
198
+ end
199
+ end
200
+
201
+ def reset_state
202
+ set_comm_inactivity_timeout @options[:timeout] if @options[:timeout] > 0
203
+ @code = 0
204
+ @headers = {}
205
+ @state = :init
206
+ @buffer = BufferedTokenizer.new("\r", MAX_LINE_LENGTH)
207
+ @stream = ''
208
+
209
+ @parser = Http::Parser.new
210
+ @parser.on_headers_complete = method(:handle_headers_complete)
211
+ @parser.on_body = method(:receive_stream_data)
212
+ end
213
+
214
+ # Called when the status line and all headers have been read from the
215
+ # stream.
216
+ def handle_headers_complete(headers)
217
+ @code = @parser.status_code.to_i
218
+ if @code != 200
219
+ receive_error("invalid status code: #{@code}.")
220
+ end
221
+ self.headers = headers
222
+ @state = :stream
223
+ end
224
+
225
+ # Called every time a chunk of data is read from the connection once it has
226
+ # been opened and after the headers have been processed.
227
+ def receive_stream_data(data)
228
+ begin
229
+ @buffer.extract(data).each do |line|
230
+ parse_stream_line(line)
231
+ end
232
+ @stream = ''
233
+ rescue => e
234
+ receive_error("#{e.class}: " + [e.message, e.backtrace].flatten.join("\n\t"))
235
+ close_connection
236
+ return
237
+ end
238
+ end
239
+
240
+ def send_request
241
+ data = []
242
+ request_uri = @options[:path]
243
+
244
+ if @proxy
245
+ # proxies need the request to be for the full url
246
+ request_uri = "#{uri_base}:#{@options[:port]}#{request_uri}"
247
+ end
248
+
249
+ content = @options[:content]
250
+
251
+ unless (q = query).empty?
252
+ if @options[:method].to_s.upcase == 'GET'
253
+ request_uri << "?#{q}"
254
+ else
255
+ content = q
256
+ end
257
+ end
258
+
259
+ data << "#{@options[:method]} #{request_uri} HTTP/1.1"
260
+ data << "Host: #{@options[:host]}"
261
+ data << 'Accept: */*'
262
+ data << "User-Agent: #{@options[:user_agent]}" if @options[:user_agent]
263
+
264
+ if @options[:auth]
265
+ data << "Authorization: Basic #{[@options[:auth]].pack('m').delete("\r\n")}"
266
+ elsif @options[:oauth]
267
+ data << "Authorization: #{oauth_header}"
268
+ end
269
+
270
+ if @proxy && @proxy.user
271
+ data << "Proxy-Authorization: Basic " + ["#{@proxy.user}:#{@proxy.password}"].pack('m').delete("\r\n")
272
+ end
273
+ if ['POST', 'PUT'].include?(@options[:method])
274
+ data << "Content-type: #{@options[:content_type]}"
275
+ data << "Content-length: #{content.length}"
276
+ end
277
+
278
+ if @options[:headers]
279
+ @options[:headers].each do |name,value|
280
+ data << "#{name}: #{value}"
281
+ end
282
+ end
283
+
284
+ data << "\r\n"
285
+
286
+ send_data data.join("\r\n") << content
287
+ end
288
+
289
+ def receive_error e
290
+ @error_callback.call(e) if @error_callback
291
+ end
292
+
293
+ def parse_stream_line ln
294
+ ln.strip!
295
+ unless ln.empty?
296
+ if ln[0,1] == '{' || ln[ln.length-1,1] == '}'
297
+ @stream << ln
298
+ if @stream[0,1] == '{' && @stream[@stream.length-1,1] == '}'
299
+ @each_item_callback.call(@stream) if @each_item_callback
300
+ @stream = ''
301
+ end
302
+ end
303
+ end
304
+ end
305
+
306
+ def reset_timeouts
307
+ set_comm_inactivity_timeout @options[:timeout] if @options[:timeout] > 0
308
+ @nf_last_reconnect = @af_last_reconnect = nil
309
+ @reconnect_retries = 0
310
+ end
311
+
312
+ #
313
+ # URL and request components
314
+ #
315
+
316
+ # :filters => %w(miama lebron jesus)
317
+ # :oauth => {
318
+ # :consumer_key => [key],
319
+ # :consumer_secret => [token],
320
+ # :access_key => [access key],
321
+ # :access_secret => [access secret]
322
+ # }
323
+ def oauth_header
324
+ uri = uri_base + @options[:path].to_s
325
+
326
+ # The hash SimpleOAuth accepts is slightly different from that of
327
+ # ROAuth. To preserve backward compatability, fix the cache here
328
+ # so that the arguments passed in don't need to change.
329
+ oauth = {
330
+ :consumer_key => @options[:oauth][:consumer_key],
331
+ :consumer_secret => @options[:oauth][:consumer_secret],
332
+ :token => @options[:oauth][:access_key],
333
+ :token_secret => @options[:oauth][:access_secret]
334
+ }
335
+
336
+ data = ['POST', 'PUT'].include?(@options[:method]) ? params : {}
337
+
338
+ SimpleOAuth::Header.new(@options[:method], uri, data, oauth)
339
+ end
340
+
341
+ # Scheme (https if ssl, http otherwise) and host part of URL
342
+ def uri_base
343
+ "http#{'s' if @options[:ssl]}://#{@options[:host]}"
344
+ end
345
+
346
+ # Normalized query hash of escaped string keys and escaped string values
347
+ # nil values are skipped
348
+ def params
349
+ flat = {}
350
+ @options[:params].merge( :track => @options[:filters] ).each do |param, val|
351
+ next if val.to_s.empty? || (val.respond_to?(:empty?) && val.empty?)
352
+ val = val.join(",") if val.respond_to?(:join)
353
+ flat[param.to_s] = val.to_s
354
+ end
355
+ flat
356
+ end
357
+
358
+ def query
359
+ params.map{|param, value| [escape(param), escape(value)].join("=")}.sort.join("&")
360
+ end
361
+
362
+ def escape str
363
+ URI.escape(str.to_s, /[^a-zA-Z0-9\-\.\_\~]/)
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,53 @@
1
+ require 'rubygems'
2
+
3
+ gem 'rspec', '>= 2.5.0'
4
+ require 'rspec'
5
+ require 'rspec/mocks'
6
+
7
+ require 'twitter/json_stream'
8
+
9
+ def fixture_path(path)
10
+ File.join(File.dirname(__FILE__), '..', 'fixtures', path)
11
+ end
12
+
13
+ def read_fixture(path)
14
+ File.read(fixture_path(path))
15
+ end
16
+
17
+ def connect_stream(opts={}, &blk)
18
+ EM.run {
19
+ opts.merge!(:host => Host, :port => Port)
20
+ stop_in = opts.delete(:stop_in) || 0.5
21
+ unless opts[:start_server] == false
22
+ EM.start_server Host, Port, JSONServer
23
+ end
24
+ @stream = JSONStream.connect(opts)
25
+ blk.call if blk
26
+ EM.add_timer(stop_in){ EM.stop }
27
+ }
28
+ end
29
+
30
+ def http_response(status_code, status_text, headers, body)
31
+ res = "HTTP/1.1 #{status_code} #{status_text}\r\n"
32
+ headers = {
33
+ "Content-Type"=>"application/json",
34
+ "Transfer-Encoding"=>"chunked"
35
+ }.merge(headers)
36
+ headers.each do |key,value|
37
+ res << "#{key}: #{value}\r\n"
38
+ end
39
+ res << "\r\n"
40
+ if headers["Transfer-Encoding"] == "chunked" && body.kind_of?(Array)
41
+ body.each do |data|
42
+ res << http_chunk(data)
43
+ end
44
+ else
45
+ res << body
46
+ end
47
+ res
48
+ end
49
+
50
+ def http_chunk(data)
51
+ # See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1
52
+ "#{data.length.to_s(16)}\r\n#{data}\r\n"
53
+ end
@@ -0,0 +1,335 @@
1
+ require 'spec_helper.rb'
2
+ require 'twitter/json_stream'
3
+
4
+ include Twitter
5
+
6
+ Host = "127.0.0.1"
7
+ Port = 9550
8
+
9
+ class JSONServer < EM::Connection
10
+ attr_accessor :data
11
+ def receive_data data
12
+ $recieved_data = data
13
+ send_data $data_to_send
14
+ EventMachine.next_tick {
15
+ close_connection if $close_connection
16
+ }
17
+ end
18
+ end
19
+
20
+
21
+
22
+ describe JSONStream do
23
+
24
+ context "authentication" do
25
+ it "should connect with basic auth credentials" do
26
+ connect_stream :auth => "username:password", :ssl => false
27
+ $recieved_data.should include('Authorization: Basic')
28
+ end
29
+
30
+ it "should connect with oauth credentials" do
31
+ oauth = {
32
+ :consumer_key => '1234567890',
33
+ :consumer_secret => 'abcdefghijklmnopqrstuvwxyz',
34
+ :access_key => 'ohai',
35
+ :access_secret => 'ohno'
36
+ }
37
+ connect_stream :oauth => oauth, :ssl => false
38
+ $recieved_data.should include('Authorization: OAuth')
39
+ end
40
+ end
41
+
42
+ context "on create" do
43
+
44
+ it "should return stream" do
45
+ EM.should_receive(:connect).and_return('TEST INSTANCE')
46
+ stream = JSONStream.connect {}
47
+ stream.should == 'TEST INSTANCE'
48
+ end
49
+
50
+ it "should define default properties" do
51
+ EM.should_receive(:connect).with do |host, port, handler, opts|
52
+ host.should == 'stream.twitter.com'
53
+ port.should == 443
54
+ opts[:path].should == '/1/statuses/filter.json'
55
+ opts[:method].should == 'GET'
56
+ end
57
+ stream = JSONStream.connect {}
58
+ end
59
+
60
+ it "should connect to the proxy if provided" do
61
+ EM.should_receive(:connect).with do |host, port, handler, opts|
62
+ host.should == 'my-proxy'
63
+ port.should == 8080
64
+ opts[:host].should == 'stream.twitter.com'
65
+ opts[:port].should == 443
66
+ opts[:proxy].should == 'http://my-proxy:8080'
67
+ end
68
+ stream = JSONStream.connect(:proxy => "http://my-proxy:8080") {}
69
+ end
70
+
71
+ it "should not trigger SSL until connection is established" do
72
+ connection = stub('connection')
73
+ EM.should_receive(:connect).and_return(connection)
74
+ stream = JSONStream.connect(:ssl => true)
75
+ stream.should == connection
76
+ end
77
+
78
+ end
79
+
80
+ context "on valid stream" do
81
+ attr_reader :stream
82
+ before :each do
83
+ $body = File.readlines(fixture_path("twitter/tweets.txt"))
84
+ $body.each {|tweet| tweet.strip!; tweet << "\r" }
85
+ $data_to_send = http_response(200, "OK", {}, $body)
86
+ $recieved_data = ''
87
+ $close_connection = false
88
+ end
89
+
90
+ it "should add no params" do
91
+ connect_stream :ssl => false
92
+ $recieved_data.should include('/1/statuses/filter.json HTTP')
93
+ end
94
+
95
+ it "should add custom params" do
96
+ connect_stream :params => {:name => 'test'}, :ssl => false
97
+ $recieved_data.should include('?name=test')
98
+ end
99
+
100
+ it "should parse headers" do
101
+ connect_stream :ssl => false
102
+ stream.code.should == 200
103
+ stream.headers.keys.map{|k| k.downcase}.should include('content-type')
104
+ end
105
+
106
+ it "should parse headers even after connection close" do
107
+ connect_stream :ssl => false
108
+ stream.code.should == 200
109
+ stream.headers.keys.map{|k| k.downcase}.should include('content-type')
110
+ end
111
+
112
+ it "should extract records" do
113
+ connect_stream :user_agent => 'TEST_USER_AGENT', :ssl => false
114
+ $recieved_data.upcase.should include('USER-AGENT: TEST_USER_AGENT')
115
+ end
116
+
117
+ it 'should allow custom headers' do
118
+ connect_stream :headers => { 'From' => 'twitter-stream' }, :ssl => false
119
+ $recieved_data.upcase.should include('FROM: TWITTER-STREAM')
120
+ end
121
+
122
+ it "should deliver each item" do
123
+ items = []
124
+ connect_stream :ssl => false do
125
+ stream.each_item do |item|
126
+ items << item
127
+ end
128
+ end
129
+ # Extract only the tweets from the fixture
130
+ tweets = $body.map{|l| l.strip }.select{|l| l =~ /^\{/ }
131
+ items.size.should == tweets.size
132
+ tweets.each_with_index do |tweet,i|
133
+ items[i].should == tweet
134
+ end
135
+ end
136
+
137
+ it "should swallow StandardError exceptions when delivering items" do
138
+ expect do
139
+ connect_stream :ssl => false do
140
+ stream.each_item { |item| raise StandardError, 'error message' }
141
+ end
142
+ end.to_not raise_error
143
+ end
144
+
145
+
146
+ it "propagates out runtime errors when delivering items" do
147
+ expect do
148
+ connect_stream :ssl => false do
149
+ stream.each_item { |item| raise Exception, 'error message' }
150
+ end
151
+ end.to raise_error(Exception, 'error message')
152
+ end
153
+
154
+ it "should send correct user agent" do
155
+ connect_stream
156
+ end
157
+ end
158
+
159
+ shared_examples_for "network failure" do
160
+ it "should reconnect on network failure" do
161
+ connect_stream do
162
+ stream.should_receive(:reconnect)
163
+ end
164
+ end
165
+
166
+ it "should not reconnect on network failure when not configured to auto reconnect" do
167
+ connect_stream(:auto_reconnect => false) do
168
+ stream.should_receive(:reconnect).never
169
+ end
170
+ end
171
+
172
+ it "should reconnect with 0.25 at base" do
173
+ connect_stream do
174
+ stream.should_receive(:reconnect_after).with(0.25)
175
+ end
176
+ end
177
+
178
+ it "should reconnect with linear timeout" do
179
+ connect_stream do
180
+ stream.nf_last_reconnect = 1
181
+ stream.should_receive(:reconnect_after).with(1.25)
182
+ end
183
+ end
184
+
185
+ it "should stop reconnecting after 100 times" do
186
+ connect_stream do
187
+ stream.reconnect_retries = 100
188
+ stream.should_not_receive(:reconnect_after)
189
+ end
190
+ end
191
+
192
+ it "should notify after reconnect limit is reached" do
193
+ timeout, retries = nil, nil
194
+ connect_stream do
195
+ stream.on_max_reconnects do |t, r|
196
+ timeout, retries = t, r
197
+ end
198
+ stream.reconnect_retries = 100
199
+ end
200
+ timeout.should == 0.25
201
+ retries.should == 101
202
+ end
203
+ end
204
+
205
+ context "on network failure" do
206
+ attr_reader :stream
207
+ before :each do
208
+ $data_to_send = ''
209
+ $close_connection = true
210
+ end
211
+
212
+ it "should timeout on inactivity" do
213
+ connect_stream :stop_in => 1.5 do
214
+ stream.should_receive(:reconnect)
215
+ end
216
+ end
217
+
218
+ it "should not reconnect on inactivity when not configured to auto reconnect" do
219
+ connect_stream(:stop_in => 1.5, :auto_reconnect => false) do
220
+ stream.should_receive(:reconnect).never
221
+ end
222
+ end
223
+
224
+ it "should reconnect with SSL if enabled" do
225
+ connect_stream :ssl => true do
226
+ stream.should_receive(:start_tls).twice
227
+ end
228
+ end
229
+
230
+ it_should_behave_like "network failure"
231
+ end
232
+
233
+ context "on no data received" do
234
+ attr_reader :stream
235
+ before :each do
236
+ $data_to_send = ''
237
+ $close_connection = false
238
+ end
239
+
240
+ it "should call no data callback after no data received for 90 seconds" do
241
+ connect_stream :stop_in => 6 do
242
+ stream.last_data_received_at = Time.now - 88
243
+ stream.should_receive(:no_data).once
244
+ end
245
+ end
246
+
247
+ end
248
+
249
+ context "on server unavailable" do
250
+
251
+ attr_reader :stream
252
+
253
+ # This is to make it so the network failure specs which call connect_stream
254
+ # can be reused. This way calls to connect_stream won't actually create a
255
+ # server to listen in.
256
+ def connect_stream_without_server(opts={},&block)
257
+ connect_stream_default(opts.merge(:start_server=>false),&block)
258
+ end
259
+ alias_method :connect_stream_default, :connect_stream
260
+ alias_method :connect_stream, :connect_stream_without_server
261
+
262
+ it_should_behave_like "network failure"
263
+ end
264
+
265
+ context "on application failure" do
266
+ attr_reader :stream
267
+ before :each do
268
+ $data_to_send = "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"Firehose\"\r\n\r\n"
269
+ $close_connection = false
270
+ end
271
+
272
+ it "should reconnect on application failure 10 at base" do
273
+ connect_stream :ssl => false do
274
+ stream.should_receive(:reconnect_after).with(10)
275
+ end
276
+ end
277
+
278
+ it "should not reconnect on application failure 10 at base when not configured to auto reconnect" do
279
+ connect_stream :ssl => false, :auto_reconnect => false do
280
+ stream.should_receive(:reconnect_after).never
281
+ end
282
+ end
283
+
284
+ it "should reconnect with exponential timeout" do
285
+ connect_stream :ssl => false do
286
+ stream.af_last_reconnect = 160
287
+ stream.should_receive(:reconnect_after).with(320)
288
+ end
289
+ end
290
+
291
+ it "should not try to reconnect after limit is reached" do
292
+ connect_stream :ssl => false do
293
+ stream.af_last_reconnect = 320
294
+ stream.should_not_receive(:reconnect_after)
295
+ end
296
+ end
297
+ end
298
+
299
+ context "on stream with chunked transfer encoding" do
300
+ attr_reader :stream
301
+ before :each do
302
+ $recieved_data = ''
303
+ $close_connection = false
304
+ end
305
+
306
+ it "should ignore empty lines" do
307
+ body_chunks = ["{\"screen"+"_name\"",":\"user1\"}\r\r\r{","\"id\":9876}\r\r"]
308
+ $data_to_send = http_response(200,"OK",{},body_chunks)
309
+ items = []
310
+ connect_stream :ssl => false do
311
+ stream.each_item do |item|
312
+ items << item
313
+ end
314
+ end
315
+ items.size.should == 2
316
+ items[0].should == '{"screen_name":"user1"}'
317
+ items[1].should == '{"id":9876}'
318
+ end
319
+
320
+ it "should parse full entities even if split" do
321
+ body_chunks = ["{\"id\"",":1234}\r{","\"id\":9876}"]
322
+ $data_to_send = http_response(200,"OK",{},body_chunks)
323
+ items = []
324
+ connect_stream :ssl => false do
325
+ stream.each_item do |item|
326
+ items << item
327
+ end
328
+ end
329
+ items.size.should == 2
330
+ items[0].should == '{"id":1234}'
331
+ items[1].should == '{"id":9876}'
332
+ end
333
+ end
334
+
335
+ end
@@ -0,0 +1,32 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = %q{cantino-twitter-stream}
5
+ s.version = "0.1.15"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["Andrew Cantino", "Vladimir Kolesnikov"]
9
+ s.date = %q{2012-04-10}
10
+ s.description = %q{Simple Ruby client library for twitter streaming API. Uses EventMachine for connection handling. Adheres to twitter's reconnection guidline. JSON format only.}
11
+ s.summary = %q{Twitter realtime API client- updated to use nerver version of http_parser}
12
+ s.homepage = %q{http://github.com/cantino/twitter-stream}
13
+ s.email = %q{voloko@gmail.com}
14
+
15
+ s.platform = Gem::Platform::RUBY
16
+ s.rubygems_version = %q{1.3.6}
17
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.3.6") if s.respond_to? :required_rubygems_version=
18
+
19
+ s.rdoc_options = ["--charset=UTF-8"]
20
+ s.extra_rdoc_files = ["README.markdown", "LICENSE"]
21
+
22
+ s.add_runtime_dependency('eventmachine', ">= 0.12.8")
23
+ s.add_runtime_dependency('simple_oauth', '~> 0.2.0')
24
+ s.add_runtime_dependency('http_parser.rb', '~> 0.6.0')
25
+ s.add_development_dependency('rspec', "~> 2.5.0")
26
+
27
+ s.files = `git ls-files`.split("\n")
28
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
29
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
30
+ s.require_paths = ["lib"]
31
+ end
32
+
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cantino-twitter-stream
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.15
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Cantino
8
+ - Vladimir Kolesnikov
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-04-10 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: eventmachine
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - '>='
19
+ - !ruby/object:Gem::Version
20
+ version: 0.12.8
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - '>='
26
+ - !ruby/object:Gem::Version
27
+ version: 0.12.8
28
+ - !ruby/object:Gem::Dependency
29
+ name: simple_oauth
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ~>
33
+ - !ruby/object:Gem::Version
34
+ version: 0.2.0
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ~>
40
+ - !ruby/object:Gem::Version
41
+ version: 0.2.0
42
+ - !ruby/object:Gem::Dependency
43
+ name: http_parser.rb
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ~>
47
+ - !ruby/object:Gem::Version
48
+ version: 0.6.0
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ~>
54
+ - !ruby/object:Gem::Version
55
+ version: 0.6.0
56
+ - !ruby/object:Gem::Dependency
57
+ name: rspec
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ~>
61
+ - !ruby/object:Gem::Version
62
+ version: 2.5.0
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ~>
68
+ - !ruby/object:Gem::Version
69
+ version: 2.5.0
70
+ description: Simple Ruby client library for twitter streaming API. Uses EventMachine
71
+ for connection handling. Adheres to twitter's reconnection guidline. JSON format
72
+ only.
73
+ email: voloko@gmail.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files:
77
+ - README.markdown
78
+ - LICENSE
79
+ files:
80
+ - .gemtest
81
+ - .gitignore
82
+ - .rspec
83
+ - Gemfile
84
+ - LICENSE
85
+ - README.markdown
86
+ - Rakefile
87
+ - VERSION
88
+ - examples/reader.rb
89
+ - fixtures/twitter/tweets.txt
90
+ - lib/twitter/json_stream.rb
91
+ - spec/spec_helper.rb
92
+ - spec/twitter/json_stream_spec.rb
93
+ - twitter-stream.gemspec
94
+ homepage: http://github.com/cantino/twitter-stream
95
+ licenses: []
96
+ metadata: {}
97
+ post_install_message:
98
+ rdoc_options:
99
+ - --charset=UTF-8
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - '>='
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - '>='
110
+ - !ruby/object:Gem::Version
111
+ version: 1.3.6
112
+ requirements: []
113
+ rubyforge_project:
114
+ rubygems_version: 2.0.3
115
+ signing_key:
116
+ specification_version: 4
117
+ summary: Twitter realtime API client- updated to use nerver version of http_parser
118
+ test_files:
119
+ - spec/spec_helper.rb
120
+ - spec/twitter/json_stream_spec.rb