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