nervion 0.0.1
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/.gitignore +4 -0
- data/.rspec +2 -0
- data/.rvmrc +1 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +46 -0
- data/LICENSE +22 -0
- data/README.md +197 -0
- data/Rakefile +22 -0
- data/features/step_definitions/streaming_steps.rb +55 -0
- data/features/streaming.feature +16 -0
- data/features/support/env.rb +9 -0
- data/features/support/streaming_api_double.rb +45 -0
- data/fixtures/responses.rb +53 -0
- data/fixtures/stream.txt +100 -0
- data/lib/nervion.rb +4 -0
- data/lib/nervion/callback_table.rb +30 -0
- data/lib/nervion/client.rb +23 -0
- data/lib/nervion/configuration.rb +50 -0
- data/lib/nervion/facade.rb +54 -0
- data/lib/nervion/http_parser.rb +62 -0
- data/lib/nervion/oauth_header.rb +88 -0
- data/lib/nervion/oauth_signature.rb +54 -0
- data/lib/nervion/percent_encoder.rb +11 -0
- data/lib/nervion/reconnection_scheduler.rb +96 -0
- data/lib/nervion/request.rb +99 -0
- data/lib/nervion/stream.rb +64 -0
- data/lib/nervion/stream_handler.rb +42 -0
- data/lib/nervion/version.rb +3 -0
- data/nervion.gemspec +24 -0
- data/spec/nervion/callback_table_spec.rb +18 -0
- data/spec/nervion/client_spec.rb +51 -0
- data/spec/nervion/configuration_spec.rb +58 -0
- data/spec/nervion/facade_spec.rb +90 -0
- data/spec/nervion/http_parser_spec.rb +26 -0
- data/spec/nervion/oauth_header_spec.rb +115 -0
- data/spec/nervion/oauth_signature_spec.rb +66 -0
- data/spec/nervion/percent_encoder_spec.rb +20 -0
- data/spec/nervion/reconnection_scheduler_spec.rb +84 -0
- data/spec/nervion/request_spec.rb +90 -0
- data/spec/nervion/stream_handler_spec.rb +67 -0
- data/spec/nervion/stream_spec.rb +97 -0
- data/spec/spec_helper.rb +4 -0
- metadata +200 -0
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
|
3
|
+
module Nervion
|
4
|
+
class ReconnectionScheduler
|
5
|
+
HTTP_ERROR_LIMIT = 10
|
6
|
+
NETWORK_ERROR_LIMIT = 65
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@http_errors = ErrorCounter.new(HTTP_ERROR_LIMIT)
|
10
|
+
@network_errors = ErrorCounter.new(NETWORK_ERROR_LIMIT)
|
11
|
+
@http_wait_calculator = HttpWaitCalculator.new
|
12
|
+
@network_wait_calculator = NetworkWaitCalculator.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def reconnect_after_http_error_in(stream)
|
16
|
+
reconnect_after_error stream, @http_errors, @http_wait_calculator
|
17
|
+
end
|
18
|
+
|
19
|
+
def reconnect_after_network_error_in(stream)
|
20
|
+
reconnect_after_error stream, @network_errors, @network_wait_calculator
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def reconnect_after_error(stream, errors, wait_calculator)
|
26
|
+
errors.notify_error
|
27
|
+
schedule_reconnect stream, wait_calculator.wait_after(errors.count)
|
28
|
+
end
|
29
|
+
|
30
|
+
def schedule_reconnect(stream, seconds)
|
31
|
+
EM.add_timer(seconds) { stream.retry }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class ErrorCounter
|
36
|
+
attr_reader :count
|
37
|
+
|
38
|
+
def initialize(limit)
|
39
|
+
@count = 0
|
40
|
+
@limit = limit
|
41
|
+
end
|
42
|
+
|
43
|
+
def notify_error
|
44
|
+
@count += 1
|
45
|
+
raise TooManyConnectionErrors if too_many_errors?
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def too_many_errors?
|
51
|
+
@count >= @limit
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class WaitCalculator
|
56
|
+
def initialize(max_wait, &calculator)
|
57
|
+
@max_wait = max_wait
|
58
|
+
@calculator = calculator
|
59
|
+
end
|
60
|
+
|
61
|
+
def wait_after(error_count)
|
62
|
+
cap_wait @max_wait, calculate_wait_after(error_count)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def cap_wait(cap_value, current_wait)
|
68
|
+
[current_wait, cap_value].min
|
69
|
+
end
|
70
|
+
|
71
|
+
def calculate_wait_after(error_count)
|
72
|
+
@calculator.call(error_count)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
class HttpWaitCalculator < WaitCalculator
|
77
|
+
MIN_WAIT = 10
|
78
|
+
MAX_WAIT = 240
|
79
|
+
|
80
|
+
def initialize
|
81
|
+
super(MAX_WAIT) { |error_count| MIN_WAIT * 2**(error_count - 1) }
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
class NetworkWaitCalculator < WaitCalculator
|
86
|
+
MIN_WAIT = 0.25
|
87
|
+
MAX_WAIT = 16
|
88
|
+
|
89
|
+
def initialize
|
90
|
+
super(MAX_WAIT) { |error_count| MIN_WAIT * error_count }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
class TooManyConnectionErrors < Exception
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
require_relative 'oauth_header'
|
2
|
+
|
3
|
+
module Nervion
|
4
|
+
def self.get(uri, params = {}, oauth_params)
|
5
|
+
Get.new uri, params, oauth_params
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.post(uri, params = {}, oauth_params)
|
9
|
+
Post.new uri, params, oauth_params
|
10
|
+
end
|
11
|
+
|
12
|
+
module Request
|
13
|
+
attr_reader :params, :oauth_params
|
14
|
+
|
15
|
+
def initialize(uri, params, oauth_params)
|
16
|
+
@uri = URI.parse(uri)
|
17
|
+
@params = params
|
18
|
+
@oauth_params = oauth_params
|
19
|
+
end
|
20
|
+
|
21
|
+
def uri
|
22
|
+
@uri.to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
def path
|
26
|
+
@uri.request_uri
|
27
|
+
end
|
28
|
+
|
29
|
+
def host
|
30
|
+
@uri.host
|
31
|
+
end
|
32
|
+
|
33
|
+
def port
|
34
|
+
@uri.port
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def request_line
|
40
|
+
"#{http_method} #{path} HTTP/1.1"
|
41
|
+
end
|
42
|
+
|
43
|
+
def headers
|
44
|
+
[ "Host: #{host}", "Authorization: #{OAuthHeader.for(self)}" ]
|
45
|
+
end
|
46
|
+
|
47
|
+
def percent_encode(params)
|
48
|
+
params.map do |name, value|
|
49
|
+
"#{name.to_s}=#{PercentEncoder.encode(value.to_s)}"
|
50
|
+
end.join '&'
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class Get
|
55
|
+
include Request
|
56
|
+
|
57
|
+
def path
|
58
|
+
if params.any?
|
59
|
+
"#{super}?#{percent_encode(params)}"
|
60
|
+
else
|
61
|
+
super
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def to_s
|
66
|
+
"#{request_line}\r\n#{headers.join("\r\n")}\r\n\r\n"
|
67
|
+
end
|
68
|
+
|
69
|
+
def http_method
|
70
|
+
'GET'
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
class Post
|
75
|
+
include Request
|
76
|
+
include PercentEncoder
|
77
|
+
|
78
|
+
def to_s
|
79
|
+
"#{request_line}\r\n#{headers.join("\r\n")}\r\n\r\n#{body}\r\n"
|
80
|
+
end
|
81
|
+
|
82
|
+
def http_method
|
83
|
+
'POST'
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def headers
|
89
|
+
super << [
|
90
|
+
'Content-Type: application/x-www-form-urlencoded',
|
91
|
+
"Content-Length: #{body.length}"
|
92
|
+
]
|
93
|
+
end
|
94
|
+
|
95
|
+
def body
|
96
|
+
percent_encode params
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
require 'nervion/http_parser'
|
3
|
+
require 'nervion/reconnection_scheduler'
|
4
|
+
|
5
|
+
module Nervion
|
6
|
+
class Stream < EM::Connection
|
7
|
+
|
8
|
+
attr_reader :http_error
|
9
|
+
|
10
|
+
def initialize(*args)
|
11
|
+
@request = args[0]
|
12
|
+
@handler = args[1]
|
13
|
+
end
|
14
|
+
|
15
|
+
def post_init
|
16
|
+
@scheduler = ReconnectionScheduler.new
|
17
|
+
end
|
18
|
+
|
19
|
+
def connection_completed
|
20
|
+
start_tls
|
21
|
+
send_data @request
|
22
|
+
end
|
23
|
+
|
24
|
+
def receive_data(data)
|
25
|
+
@handler << data
|
26
|
+
rescue HttpError => error
|
27
|
+
@http_error = error
|
28
|
+
end
|
29
|
+
|
30
|
+
def retry
|
31
|
+
@http_error = nil
|
32
|
+
reconnect @request.host, @request.port
|
33
|
+
end
|
34
|
+
|
35
|
+
def unbind
|
36
|
+
handle_closed_stream unless @handler.stream_close_requested?
|
37
|
+
end
|
38
|
+
|
39
|
+
def http_error_occurred?
|
40
|
+
not http_error.nil?
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def handle_closed_stream
|
46
|
+
if http_error_occurred?
|
47
|
+
handle_http_error_and_reopen_stream
|
48
|
+
else
|
49
|
+
handle_network_error_and_reopen_stream
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def handle_http_error_and_reopen_stream
|
54
|
+
@handler.handle_http_error http_error
|
55
|
+
@scheduler.reconnect_after_http_error_in self
|
56
|
+
end
|
57
|
+
|
58
|
+
def handle_network_error_and_reopen_stream
|
59
|
+
@handler.handle_network_error
|
60
|
+
@scheduler.reconnect_after_network_error_in self
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'yajl'
|
2
|
+
require 'nervion/http_parser'
|
3
|
+
|
4
|
+
module Nervion
|
5
|
+
class StreamHandler
|
6
|
+
|
7
|
+
def initialize(callbacks)
|
8
|
+
@callbacks = callbacks
|
9
|
+
@http_parser = HttpParser.new(setup_json_parser)
|
10
|
+
end
|
11
|
+
|
12
|
+
def <<(data)
|
13
|
+
@http_parser << data
|
14
|
+
end
|
15
|
+
|
16
|
+
def handle_http_error(error)
|
17
|
+
@http_parser.reset!
|
18
|
+
@callbacks[:http_error].call(error.status, error.body)
|
19
|
+
end
|
20
|
+
|
21
|
+
def handle_network_error
|
22
|
+
@http_parser.reset!
|
23
|
+
@callbacks[:network_error].call
|
24
|
+
end
|
25
|
+
|
26
|
+
def stream_close_requested?
|
27
|
+
@close_stream ||= false
|
28
|
+
end
|
29
|
+
|
30
|
+
def close_stream
|
31
|
+
@close_stream = true
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def setup_json_parser
|
37
|
+
Yajl::Parser.new(symbolize_keys: true).tap do |json_parser|
|
38
|
+
json_parser.on_parse_complete = @callbacks[:status]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/nervion.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/nervion/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Javier Acero"]
|
6
|
+
gem.email = ["j4cegu@gmail.com"]
|
7
|
+
gem.description = %q{A minimalistic Twitter Stream API Ruby client}
|
8
|
+
gem.summary = %q{}
|
9
|
+
gem.homepage = "https://github.com/jacegu/nervion"
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(spec|features)/})
|
14
|
+
gem.name = "nervion"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = Nervion::VERSION
|
17
|
+
|
18
|
+
gem.add_runtime_dependency 'eventmachine', '~> 1.0.0.beta.4'
|
19
|
+
gem.add_runtime_dependency 'http_parser.rb', '~> 0.5.3'
|
20
|
+
gem.add_runtime_dependency 'yajl-ruby', '~> 1.1.0'
|
21
|
+
gem.add_development_dependency 'rspec'
|
22
|
+
gem.add_development_dependency 'cucumber'
|
23
|
+
gem.add_development_dependency 'simplecov'
|
24
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'nervion/callback_table'
|
2
|
+
|
3
|
+
describe Nervion::CallbackTable do
|
4
|
+
it 'can be setup with a callback' do
|
5
|
+
callback = stub(:callback)
|
6
|
+
subject[:name]= callback
|
7
|
+
subject[:name].should be callback
|
8
|
+
end
|
9
|
+
|
10
|
+
it 'has a callback for network errors by default' do
|
11
|
+
subject[:network_error].should_not be_nil
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'has a callback for http errors by default' do
|
15
|
+
STDERR.should_receive(:puts).with(/500|error/)
|
16
|
+
subject[:http_error].call(500, 'error')
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
require 'nervion/client'
|
3
|
+
|
4
|
+
describe Nervion::Client do
|
5
|
+
subject { described_class.new('http://twitter.com', 443) }
|
6
|
+
let(:request) { stub :request }
|
7
|
+
let(:callbacks) { stub :callbacks }
|
8
|
+
let(:stream_handler) { stub :stream_handler }
|
9
|
+
|
10
|
+
before(:all) do
|
11
|
+
module EventMachine
|
12
|
+
class << self
|
13
|
+
alias old_run run
|
14
|
+
alias old_connect connect
|
15
|
+
end
|
16
|
+
def self.run; yield; end
|
17
|
+
def self.connect(*args); end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
after(:all) do
|
22
|
+
module EM
|
23
|
+
class << self
|
24
|
+
alias run old_run
|
25
|
+
alias connect old_connect
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
before do
|
31
|
+
Nervion::StreamHandler.stub(:new).with(callbacks).and_return(stream_handler)
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'starts treaming' do
|
35
|
+
EM.should_receive(:connect).with(
|
36
|
+
'http://twitter.com',
|
37
|
+
443,
|
38
|
+
Nervion::Stream,
|
39
|
+
request,
|
40
|
+
stream_handler
|
41
|
+
)
|
42
|
+
subject.stream(request, callbacks)
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'stops streaming' do
|
46
|
+
stream_handler.should_receive(:close_stream).ordered
|
47
|
+
EM.should_receive(:stop).ordered
|
48
|
+
subject.stream(request, callbacks)
|
49
|
+
subject.stop
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'nervion/configuration'
|
2
|
+
|
3
|
+
describe Nervion::Configuration do
|
4
|
+
|
5
|
+
it 'allows configuration from the top level' do
|
6
|
+
Nervion::Configuration.should_receive(:access_key=).with 'access_key'
|
7
|
+
Nervion.configure { |config| config.access_key = 'access_key' }
|
8
|
+
end
|
9
|
+
|
10
|
+
context 'when it has not been configured' do
|
11
|
+
it 'has an empty string as consumer_key' do
|
12
|
+
described_class.consumer_key.should eq ''
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'has an empty string as consumer secret' do
|
16
|
+
described_class.consumer_secret.should eq ''
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'has an empty string as access token' do
|
20
|
+
described_class.access_token.should eq ''
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'has an empty string as access token secret' do
|
24
|
+
described_class.access_token_secret.should eq ''
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'configuration' do
|
29
|
+
it 'can be set with the consumer key' do
|
30
|
+
described_class.consumer_key = 'consumer_key'
|
31
|
+
described_class.consumer_key.should eq 'consumer_key'
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'can be set with the consumer secret' do
|
35
|
+
described_class.consumer_secret = 'consumer_secret'
|
36
|
+
described_class.consumer_secret.should eq 'consumer_secret'
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'can be set with the access token' do
|
40
|
+
described_class.access_token = 'access token'
|
41
|
+
described_class.access_token.should eq 'access token'
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'can be set with the access token secret' do
|
45
|
+
described_class.access_token_secret = 'access token secret'
|
46
|
+
described_class.access_token_secret.should eq 'access token secret'
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context 'accessing configuration' do
|
51
|
+
it 'settings can de accessed as if it was hash' do
|
52
|
+
described_class.consumer_secret = 'consumer_secret'
|
53
|
+
described_class[:consumer_secret].should eq 'consumer_secret'
|
54
|
+
described_class.fetch(:consumer_secret).should eq 'consumer_secret'
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|