nervion 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. data/.gitignore +4 -0
  2. data/.rspec +2 -0
  3. data/.rvmrc +1 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +46 -0
  6. data/LICENSE +22 -0
  7. data/README.md +197 -0
  8. data/Rakefile +22 -0
  9. data/features/step_definitions/streaming_steps.rb +55 -0
  10. data/features/streaming.feature +16 -0
  11. data/features/support/env.rb +9 -0
  12. data/features/support/streaming_api_double.rb +45 -0
  13. data/fixtures/responses.rb +53 -0
  14. data/fixtures/stream.txt +100 -0
  15. data/lib/nervion.rb +4 -0
  16. data/lib/nervion/callback_table.rb +30 -0
  17. data/lib/nervion/client.rb +23 -0
  18. data/lib/nervion/configuration.rb +50 -0
  19. data/lib/nervion/facade.rb +54 -0
  20. data/lib/nervion/http_parser.rb +62 -0
  21. data/lib/nervion/oauth_header.rb +88 -0
  22. data/lib/nervion/oauth_signature.rb +54 -0
  23. data/lib/nervion/percent_encoder.rb +11 -0
  24. data/lib/nervion/reconnection_scheduler.rb +96 -0
  25. data/lib/nervion/request.rb +99 -0
  26. data/lib/nervion/stream.rb +64 -0
  27. data/lib/nervion/stream_handler.rb +42 -0
  28. data/lib/nervion/version.rb +3 -0
  29. data/nervion.gemspec +24 -0
  30. data/spec/nervion/callback_table_spec.rb +18 -0
  31. data/spec/nervion/client_spec.rb +51 -0
  32. data/spec/nervion/configuration_spec.rb +58 -0
  33. data/spec/nervion/facade_spec.rb +90 -0
  34. data/spec/nervion/http_parser_spec.rb +26 -0
  35. data/spec/nervion/oauth_header_spec.rb +115 -0
  36. data/spec/nervion/oauth_signature_spec.rb +66 -0
  37. data/spec/nervion/percent_encoder_spec.rb +20 -0
  38. data/spec/nervion/reconnection_scheduler_spec.rb +84 -0
  39. data/spec/nervion/request_spec.rb +90 -0
  40. data/spec/nervion/stream_handler_spec.rb +67 -0
  41. data/spec/nervion/stream_spec.rb +97 -0
  42. data/spec/spec_helper.rb +4 -0
  43. metadata +200 -0
@@ -0,0 +1,90 @@
1
+ require 'nervion/facade'
2
+
3
+ describe "Facade that exposes Nervion's API" do
4
+ let(:callback_table) { mock(:callback_table).as_null_object }
5
+ let(:status_callback) { lambda { :status_callback } }
6
+ let(:http_callback) { lambda { :http_error_callback } }
7
+ let(:network_callback) { lambda { :network_error_callback } }
8
+
9
+ before { Nervion.stub(:callback_table).and_return(callback_table) }
10
+
11
+ it 'provides a call to set up the http error callback' do
12
+ callback_table.should_receive(:[]=).with(:http_error, http_callback)
13
+ Nervion.on_http_error(&http_callback)
14
+ end
15
+
16
+ it 'provides a call to set up the network error callback' do
17
+ callback_table.should_receive(:[]=).with(:network_error, network_callback)
18
+ Nervion.on_network_error(&network_callback)
19
+ end
20
+
21
+ context 'chaining callback setup calls' do
22
+ before do
23
+ callback_table.should_receive(:[]=).with(:http_error, http_callback)
24
+ callback_table.should_receive(:[]=).with(:network_error, network_callback)
25
+ end
26
+
27
+ it 'allows to chain callback setups' do
28
+ Nervion.on_http_error(&http_callback).on_network_error(&network_callback)
29
+ end
30
+
31
+ it 'callback setups can be chained in any order' do
32
+ Nervion.on_network_error(&network_callback).on_http_error(&http_callback)
33
+ end
34
+ end
35
+
36
+ context 'streaming' do
37
+ let(:client) { stub(:client).as_null_object }
38
+ let(:config) { Nervion::Configuration }
39
+ let(:params) { Hash[stall_warnings: true] }
40
+ let(:request) { stub :request }
41
+
42
+ before do
43
+ Nervion::Client.stub(:new).
44
+ with(Nervion::STREAM_API_HOST, Nervion::STREAM_API_PORT).
45
+ and_return(client)
46
+ end
47
+
48
+ context 'sample endpoint' do
49
+ it 'sets up the status callback' do
50
+ callback_table.should_receive(:[]=).with(:status, status_callback)
51
+ Nervion.sample(&status_callback)
52
+ end
53
+
54
+ it 'starts the streaming to the sample endpoint' do
55
+ Nervion.stub(:get).with(Nervion::SAMPLE_ENDPOINT, params, config).
56
+ and_return(request)
57
+ client.should_receive(:stream).with(request, callback_table)
58
+ Nervion.sample(params, &status_callback)
59
+ end
60
+ end
61
+
62
+ context 'filter endpoint' do
63
+ it 'sets up the status callback' do
64
+ callback_table.should_receive(:[]=).with(:status, status_callback)
65
+ Nervion.filter(params, &status_callback)
66
+ end
67
+
68
+ it 'starts the streaming to the filter endpoint' do
69
+ Nervion.stub(:post).with(Nervion::FILTER_ENDPOINT, params, config).
70
+ and_return(request)
71
+ client.should_receive(:stream).with(request, callback_table)
72
+ Nervion.filter(params, &status_callback)
73
+ end
74
+ end
75
+
76
+ context 'stoping' do
77
+ it 'can stop the streaming' do
78
+ client.should_receive(:stop)
79
+ Nervion.sample(->{})
80
+ Nervion.stop
81
+ end
82
+
83
+ it 'raises an error if it is not streaming' do
84
+ Nervion.instance_variable_set(:@client, nil)
85
+ expect { Nervion.stop }.to raise_error
86
+ end
87
+ end
88
+ end
89
+
90
+ end
@@ -0,0 +1,26 @@
1
+ require 'nervion/http_parser'
2
+ require 'fixtures/responses'
3
+
4
+ describe Nervion::HttpParser do
5
+ subject { described_class.new(json_parser) }
6
+ let(:json_parser) { stub(:json_parser).as_null_object }
7
+
8
+ it 'takes a JSON parser' do
9
+ subject.json_parser.should be json_parser
10
+ end
11
+
12
+ it 'can be reset' do
13
+ subject << RESPONSE_200
14
+ subject.reset!
15
+ expect { subject << RESPONSE_200 }.not_to raise_error Http::Parser::Error
16
+ end
17
+
18
+ it 'parses response body if the response status is 200' do
19
+ json_parser.should_receive(:<<).with(BODY_200)
20
+ subject << RESPONSE_200
21
+ end
22
+
23
+ it 'raises an error if the response status is above 200' do
24
+ expect { subject << RESPONSE_401 }.to raise_error Nervion::HttpError
25
+ end
26
+ end
@@ -0,0 +1,115 @@
1
+ require 'nervion/oauth_header'
2
+
3
+ describe Nervion::OAuthHeader do
4
+ subject { described_class.new http_method, base_url, params, oauth_params }
5
+
6
+ let(:http_method) { 'post' }
7
+ let(:base_url) { 'https://api.twitter.com/1/statuses/update.json' }
8
+ let(:params) { Hash[include_entities: true, status: '@patheleven'] }
9
+ let(:consumer_key) { 'xvz1evFS4wEEPTGEFPHBog' }
10
+ let(:consumer_secret) { 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw' }
11
+ let(:access_token) { 'GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb' }
12
+ let(:access_token_secret) { 'LswwdoUaIvS8ltyTth4J50vUPVVHtR2YPi5kE' }
13
+ let(:oauth_params) do
14
+ {
15
+ consumer_key: consumer_key,
16
+ consumer_secret: consumer_secret,
17
+ access_token: access_token,
18
+ access_token_secret: access_token_secret
19
+ }
20
+ end
21
+
22
+ it 'takes the consumer key from oauth params' do
23
+ subject.consumer_key.should be consumer_key
24
+ end
25
+
26
+ it 'takes the consumer secret from oauth params' do
27
+ subject.consumer_secret.should be consumer_secret
28
+ end
29
+
30
+ it 'takes the access token from oauth params' do
31
+ subject.token.should be access_token
32
+ end
33
+
34
+ it 'takes the access token secret from oauth params' do
35
+ subject.token_secret.should be access_token_secret
36
+ end
37
+
38
+ it 'nonce is md5 of a string formed by consumer key, token and timestamp' do
39
+ nonce, timestamp = stub, 123456878
40
+ subject.stub(:timestamp).and_return timestamp
41
+ string = "#{consumer_key}#{access_token}#{timestamp}"
42
+ Digest::MD5.stub(:hexdigest).with(string).and_return nonce
43
+ subject.nonce.should be nonce
44
+ end
45
+
46
+ it 'timestamp is seconds since epoch as string' do
47
+ seconds_since_epoch = stub(to_s: 'timestamp')
48
+ now = stub(to_i: seconds_since_epoch)
49
+ Time.stub(:now).and_return now
50
+ subject.timestamp.should eq 'timestamp'
51
+ end
52
+
53
+ it 'signature method is HMAC-SHA1' do
54
+ subject.signature_method.should eq 'HMAC-SHA1'
55
+ end
56
+
57
+ it 'version is 1.0' do
58
+ subject.version.should eq '1.0'
59
+ end
60
+
61
+ it 'provides a hash with the info required to create a signature' do
62
+ nonce, timestamp = stub(:nonce), stub(:timestamp)
63
+ subject.stub(:nonce).and_return nonce
64
+ subject.stub(:timestamp).and_return timestamp
65
+ oauth_info = {
66
+ oauth_consumer_key: consumer_key,
67
+ oauth_nonce: nonce,
68
+ oauth_signature_method: 'HMAC-SHA1',
69
+ oauth_timestamp: timestamp,
70
+ oauth_token: access_token,
71
+ oauth_version: '1.0'
72
+ }
73
+ subject.oauth_info.should eq oauth_info
74
+ end
75
+
76
+ it 'provides a hash with the consumer secret and token secret' do
77
+ expected_secret_hash = {
78
+ consumer_secret: consumer_secret,
79
+ access_token_secret: access_token_secret
80
+ }
81
+ subject.secrets.should eq expected_secret_hash
82
+ end
83
+
84
+ it 'creates the signature' do
85
+ signature, oauth_info = stub(:signature), stub(:oauth_info)
86
+ subject.stub(:oauth_info).and_return(oauth_info)
87
+ secrets = {
88
+ consumer_secret: consumer_secret,
89
+ access_token_secret: access_token_secret
90
+ }
91
+ Nervion::OAuthSignature.stub(:for).
92
+ with(http_method, base_url, params, oauth_info, secrets).
93
+ and_return signature
94
+ subject.signature.should be signature
95
+ end
96
+
97
+ it 'generates the authorization header value' do
98
+ subject.stub(:nonce).and_return 'nonce'
99
+ subject.stub(:timestamp).and_return 'timestamp'
100
+ subject.stub(:signature).and_return 'signature'
101
+
102
+ expected_header = %Q{OAuth oauth_consumer_key="#{consumer_key}", oauth_nonce="nonce", oauth_signature="signature", oauth_signature_method="HMAC-SHA1", oauth_timestamp="timestamp", oauth_token="#{access_token}", oauth_version="1.0"}
103
+
104
+ subject.to_s.should eq expected_header
105
+ end
106
+
107
+ it 'generates the header value for a paticular request' do
108
+ oauth_headers = stub :oauth_headers, to_s: 'OAuth oauth_params="value"'
109
+ Nervion::OAuthHeader.stub(:new).
110
+ with(http_method, base_url, params, oauth_params).and_return oauth_headers
111
+ request = stub :request, http_method: http_method, uri: base_url,
112
+ params: params, oauth_params: oauth_params
113
+ described_class.for(request).should eq 'OAuth oauth_params="value"'
114
+ end
115
+ end
@@ -0,0 +1,66 @@
1
+ require 'nervion/oauth_signature'
2
+
3
+ describe Nervion::OAuthSignature do
4
+ subject { described_class.new http_method, base_url, params, oauth_params, secrets }
5
+
6
+ let(:http_method) { 'post' }
7
+ let(:base_url) { 'https://api.twitter.com/1/statuses/update.json' }
8
+ let(:params) do
9
+ {
10
+ include_entities: true,
11
+ status: 'Hello Ladies + Gentlemen, a signed OAuth request!'
12
+ }
13
+ end
14
+ let(:oauth_params) do
15
+ {
16
+ oauth_consumer_key: 'xvz1evFS4wEEPTGEFPHBog',
17
+ oauth_nonce: 'kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg',
18
+ oauth_signature_method: 'HMAC-SHA1',
19
+ oauth_timestamp: '1318622958',
20
+ oauth_token: '370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb',
21
+ oauth_version: '1.0',
22
+ }
23
+ end
24
+ let(:secrets) do
25
+ {
26
+ consumer_secret: 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw',
27
+ access_token_secret: 'LswwdoUaIvS8ltyTth4J50vUPVVHtR2YPi5kE'
28
+ }
29
+ end
30
+
31
+ it 'percent encodes key and value and joins the result with a "="' do
32
+ subject.stub(:encode).with('include_entities').and_return 'include_entities'
33
+ subject.stub(:encode).with('true').and_return 'true'
34
+ subject.encode_pair(:include_entities, true).should eq 'include_entities=true'
35
+ end
36
+
37
+ it 'provides the parameter string' do
38
+ expected_parameter_string = %q{include_entities=true&oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1318622958&oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&oauth_version=1.0&status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20signed%20OAuth%20request%21}
39
+
40
+ subject.parameter_string.should eq expected_parameter_string
41
+ end
42
+
43
+ it 'provides the signature base string' do
44
+ expected_signature_base_string = %q{POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521}
45
+
46
+ subject.base_string.should eq expected_signature_base_string
47
+ end
48
+
49
+ it 'provides a signing key' do
50
+ expected_signing_key = 'kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw&LswwdoUaIvS8ltyTth4J50vUPVVHtR2YPi5kE'
51
+
52
+ subject.signing_key.should eq expected_signing_key
53
+ end
54
+
55
+ it 'calculates the signature' do
56
+ expected_signature = 'Fz/2gWGHnXm6+QRzVUtANvhr1wI='
57
+
58
+ subject.to_s.should eq expected_signature
59
+ end
60
+
61
+ it 'builds the signature given all the info' do
62
+ expected_signature = 'Fz/2gWGHnXm6+QRzVUtANvhr1wI='
63
+ signature = described_class.for http_method, base_url, params, oauth_params, secrets
64
+ signature.should eq expected_signature
65
+ end
66
+ end
@@ -0,0 +1,20 @@
1
+ #encoding: utf-8
2
+
3
+ require 'nervion/percent_encoder'
4
+
5
+ describe Nervion::PercentEncoder do
6
+ it 'encodes string values' do
7
+ subject.encode('Ladies + Gentlemen').should eq 'Ladies%20%2B%20Gentlemen'
8
+ subject.encode('An encoded string!').should eq 'An%20encoded%20string%21'
9
+ subject.encode('Dogs, Cats & Mice').should eq 'Dogs%2C%20Cats%20%26%20Mice'
10
+ end
11
+
12
+ it 'encodes non string values' do
13
+ subject.encode(123456789).should eq '123456789'
14
+ subject.encode(:get_post).should eq 'get_post'
15
+ end
16
+
17
+ it 'encodes UTF-8 values' do
18
+ subject.encode('☃').should eq '%E2%98%83'
19
+ end
20
+ end
@@ -0,0 +1,84 @@
1
+ require 'nervion/reconnection_scheduler'
2
+
3
+ describe Nervion::ReconnectionScheduler do
4
+ let(:stream) { stub(:connection).as_null_object }
5
+
6
+ before(:all) do
7
+ module EventMachine
8
+ class << self
9
+ alias old_add_timer add_timer
10
+ def add_timer(timeout); yield; end
11
+ end
12
+ end
13
+ end
14
+
15
+ after(:all) do
16
+ module EventMachine
17
+ class << self
18
+ alias add_timer old_add_timer
19
+ end
20
+ end
21
+ end
22
+
23
+ context 'on HTTP errors' do
24
+ it 'tells the stream to reconnect' do
25
+ stream.should_receive(:retry)
26
+ subject.reconnect_after_http_error_in stream
27
+ end
28
+
29
+ it 'waits 10 seconds before reconnecting' do
30
+ EM.should_receive(:add_timer).with Nervion::HttpWaitCalculator::MIN_WAIT
31
+ subject.reconnect_after_http_error_in stream
32
+ end
33
+
34
+ it 'increases the reconnect wait exponentially up to 240 seconds' do
35
+ [10, 20, 40, 80, 160, 240, 240, 240, 240].each do |delay|
36
+ EM.should_receive(:add_timer).with delay
37
+ subject.reconnect_after_http_error_in stream
38
+ end
39
+ end
40
+
41
+ it 'raises an error after too many unsuccessful reconnects' do
42
+ limit = described_class::HTTP_ERROR_LIMIT + 1
43
+ expect do
44
+ limit.times { subject.reconnect_after_http_error_in stream }
45
+ end.to raise_error Nervion::TooManyConnectionErrors
46
+ end
47
+ end
48
+
49
+ context 'on network errors' do
50
+ it 'tells the stream to reconnect' do
51
+ stream.should_receive(:retry)
52
+ subject.reconnect_after_network_error_in stream
53
+ end
54
+
55
+ it 'waits 250ms before reconnecting' do
56
+ EM.should_receive(:add_timer).with Nervion::NetworkWaitCalculator::MIN_WAIT
57
+ subject.reconnect_after_network_error_in stream
58
+ end
59
+
60
+ it 'increases the wait after network errors linearly up to 16 seconds' do
61
+ (0.25..16).step(0.25) do |wait|
62
+ EM.should_receive(:add_timer).with wait
63
+ subject.reconnect_after_network_error_in stream
64
+ end
65
+ end
66
+
67
+ it 'caps the wait after network errors at 16 seconds' do
68
+ errors_to_cap = (16/0.25).to_i - 1
69
+ errors_to_limit = described_class::NETWORK_ERROR_LIMIT - errors_to_cap - 1
70
+ errors_to_cap.times { subject.reconnect_after_network_error_in stream }
71
+ errors_to_limit.times do
72
+ EM.should_receive(:add_timer).with(16)
73
+ subject.reconnect_after_network_error_in stream
74
+ end
75
+ end
76
+
77
+ it 'raises an error after too many unsuccessful reconnects' do
78
+ limit = described_class::NETWORK_ERROR_LIMIT + 1
79
+ expect do
80
+ limit.times { subject.reconnect_after_network_error_in stream }
81
+ end.to raise_error Nervion::TooManyConnectionErrors
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,90 @@
1
+ require 'nervion/request'
2
+
3
+ EXPECTED_GET_REQUEST = <<GET
4
+ GET /endpoint?p1=param%20value&p2=%24%26 HTTP/1.1\r
5
+ Host: twitter.com\r
6
+ Authorization: OAuth xxx\r\n\r
7
+ GET
8
+
9
+ EXPECTED_POST_REQUEST = <<POST
10
+ POST /endpoint HTTP/1.1\r
11
+ Host: twitter.com\r
12
+ Authorization: OAuth xxx\r
13
+ Content-Type: application/x-www-form-urlencoded\r
14
+ Content-Length: 26\r
15
+ \r
16
+ p1=param%20value&p2=%24%26\r
17
+ POST
18
+
19
+ describe Nervion::Request do
20
+ let(:uri) { 'https://twitter.com:443/endpoint' }
21
+ let(:params) { Hash[p1: 'param value', p2: '$&'] }
22
+ let(:oauth_params) { Hash[param: 'value'] }
23
+
24
+ shared_examples_for 'a request' do
25
+ it 'is created with an uri' do
26
+ subject.uri.should eq 'https://twitter.com/endpoint'
27
+ end
28
+
29
+ it 'is created with http params' do
30
+ subject.params.should be params
31
+ end
32
+
33
+ it 'is created with oauth params' do
34
+ subject.oauth_params.should eq Hash[param: 'value']
35
+ end
36
+
37
+ it 'knows the host it points to' do
38
+ subject.host.should eq 'twitter.com'
39
+ end
40
+
41
+ it 'knows the port it will connect to' do
42
+ subject.port.should eq 443
43
+ end
44
+ end
45
+
46
+ context 'GET' do
47
+ subject { Nervion.get(uri, params, oauth_params) }
48
+
49
+ it 'has GET as http method' do
50
+ subject.http_method.should eq 'GET'
51
+ end
52
+
53
+ it 'knows the path it points to with no params' do
54
+ get_with_no_params = Nervion.get(uri, {}, oauth_params)
55
+ get_with_no_params.path.should eq '/endpoint'
56
+ end
57
+
58
+ it 'knows the path it points to with params' do
59
+ subject.path.should eq '/endpoint?p1=param%20value&p2=%24%26'
60
+ end
61
+
62
+
63
+ it 'has an string representation' do
64
+ Nervion::OAuthHeader.stub(:for).with(subject).and_return 'OAuth xxx'
65
+ subject.to_s.should eq EXPECTED_GET_REQUEST
66
+ end
67
+
68
+ it_behaves_like 'a request'
69
+ end
70
+
71
+ context 'POST' do
72
+ subject { Nervion.post(uri, params, oauth_params) }
73
+
74
+ it 'has POST as http method' do
75
+ subject.http_method.should eq 'POST'
76
+ end
77
+
78
+ it 'knows the path it points to' do
79
+ subject.path.should eq '/endpoint'
80
+ end
81
+
82
+ it 'has an string representation' do
83
+ post = Nervion.post(uri, params, oauth_params)
84
+ Nervion::OAuthHeader.stub(:for).with(post).and_return 'OAuth xxx'
85
+ post.to_s.should eq EXPECTED_POST_REQUEST
86
+ end
87
+
88
+ it_behaves_like 'a request'
89
+ end
90
+ end