cantino-twitter-stream 0.1.15

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