oanda_api 0.8.3 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,135 @@
1
+ module OandaAPI
2
+ module Streaming
3
+ # An HTTP 1.1 streaming request. Used to create a persistent connection
4
+ # with the server and continuously download a stream of resource
5
+ # representations. Resources are emitted as {OandaAPI::ResourceBase}
6
+ # instances.
7
+ #
8
+ # @!attribute [rw] client
9
+ # @return [OandaAPI::Streaming::Client] a streaming client instance.
10
+ #
11
+ # @!attribute [rw] emit_heartbeats
12
+ # @return [boolean]
13
+ #
14
+ # @!attribute [r] uri
15
+ # @return [URI::HTTPS] a URI instance.
16
+ #
17
+ # @!attribute [r] request
18
+ # @return [URI::HTTPS] a URI instance.
19
+ class Request
20
+ attr_accessor :client, :emit_heartbeats
21
+ attr_reader :uri, :request
22
+
23
+ # Creates an OandaAPI::Streaming::Request instance.
24
+ # @param [Streaming::Client] client a streaming client instance that can be used to
25
+ # send signals to an instance of this Streaming::Request.
26
+ # @param [String] uri an absolute URI to the service endpoint.
27
+ # @param [Hash] query a list of query parameters, unencoded. The list
28
+ # is converted into a query string. See {OandaAPI::Client#query_string_normalizer}.
29
+ # @param [Hash] headers a list of header values that will be sent with the request.
30
+ def initialize(client: nil, uri:, query: {}, headers: {})
31
+ self.client = client.nil? ? self : client
32
+ @uri = URI uri
33
+ @uri.query = OandaAPI::Client.default_options[:query_string_normalizer].call(query)
34
+ @http = Net::HTTP.new @uri.host, 443
35
+ @http.use_ssl = true
36
+ @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
37
+ @request = Net::HTTP::Get.new @uri
38
+ headers.each_pair { |pair| @request.add_field(*pair) }
39
+ end
40
+
41
+ # Sets the client attribute
42
+ # @param [OandaAPI::Streaming::Client] value
43
+ # @return [void]
44
+ # @raise [ArgumentError] if value is not an OandaAPI::Streaming::Client instance.
45
+ def client=(value)
46
+ fail ArgumentError, "Expecting an OandaAPI::Streaming::Client" unless (value.is_a?(OandaAPI::Streaming::Client) || value.is_a?(OandaAPI::Streaming::Request))
47
+ @client = value
48
+ end
49
+
50
+ # @return [boolean] true if heatbeats are emitted.
51
+ def emit_heartbeats?
52
+ !!@emit_heartbeats
53
+ end
54
+
55
+ # Signals the streaming request to disconnect and terminates streaming.
56
+ # @return [void]
57
+ def stop!
58
+ @stop_requested = true
59
+ end
60
+
61
+ # Returns `true` if the request has been signalled to terminate. See {#stop!}.
62
+ # @return [boolean]
63
+ def stop_requested?
64
+ !!@stop_requested
65
+ end
66
+
67
+ # @return [true] if the instance is connected and streaming a response.
68
+ def running?
69
+ !!@running
70
+ end
71
+
72
+ # Emits a stream of {OandaAPI::ResourceBase} instances, depending
73
+ # on the endpoint that the request is servicing, either
74
+ # {OandaAPI::Resource::Price} or {OandaAPI::Resource::Transaction}
75
+ # instances are emitted. When #emit_heartbeats? is `true`, then
76
+ # resources could also be {OandaAPI::Resource::Heartbeat}.
77
+ #
78
+ # Note this method runs as an infinite loop and will block indefinitely
79
+ # until either the connection is halted or a {#stop!} signal is recieved.
80
+ #
81
+ # @yield [OandaAPI::ResourceBase, OandaAPI::Streaming::Client] Each resource found in the response
82
+ # stream is yielded as they are received. The client instance controlling the
83
+ # streaming request is also yielded. It can be used to issue a signaller.#stop! to terminate the resquest.
84
+ # @raise [OandaAPI::StreamingDisconnect] if the endpoint was disconnected by server.
85
+ # @raise [OandaAPI::RequestError] if an unexpected resource is returned.
86
+ # @return [void]
87
+ def stream(&block)
88
+ @stop_requested = false
89
+ @running = true
90
+ # @http.set_debug_output $stderr
91
+ @http.request(@request) do |response|
92
+ response.read_body do |chunk|
93
+ handle_response(chunk).each do |resource|
94
+ block.call(resource, @client)
95
+ return if stop_requested?
96
+ end
97
+ return if stop_requested?
98
+ sleep 0.01
99
+ end
100
+ end
101
+ ensure
102
+ @running = false
103
+ @http.finish if @http.started?
104
+ end
105
+
106
+ private
107
+
108
+ # @private
109
+ # Converts a raw json response into {OandaAPI::ResourceBase} instances.
110
+ # @return [Array<OandaAPI::ResourceBase>] depending on the endpoint
111
+ # that the request is servicing, which is either an array of
112
+ # {OandaAPI::Resource::Price} or {OandaAPI::Resource::Transaction} instances.
113
+ # When #emit_heartbeats? is `true`, then the instance could be an {OandaAPI::Resource::Heartbeat}.
114
+ # @raise [OandaAPI::StreamingDisconnect] if the endpoint was disconnected by server.
115
+ # @raise [OandaAPI::RequestError] if an unexpected resource is returned.
116
+ def handle_response(response)
117
+ response.split("\r\n").map do |json|
118
+ parsed_response = JSON.parse json
119
+ case
120
+ when parsed_response["heartbeat"]
121
+ OandaAPI::Resource::Heartbeat.new parsed_response["heartbeat"] if emit_heartbeats?
122
+ when parsed_response["tick"]
123
+ OandaAPI::Resource::Price.new parsed_response["tick"]
124
+ when parsed_response["transaction"]
125
+ OandaAPI::Resource::Transaction.new parsed_response["transaction"]
126
+ when parsed_response["disconnect"]
127
+ raise OandaAPI::StreamingDisconnect, parsed_response["disconnect"]["message"]
128
+ else
129
+ raise OandaAPI::RequestError, "unknown resource: #{json}"
130
+ end
131
+ end.compact
132
+ end
133
+ end
134
+ end
135
+ end
@@ -45,7 +45,7 @@ module OandaAPI
45
45
  # Yields all keys of a hash, and safely applies whatever transform
46
46
  # the block provides. Supports nested hashes.
47
47
  #
48
- # @param [Object] value can be a +Hash+, an +Array+ or scalar object type.
48
+ # @param [Object] value can be a `Hash`, an `Array` or scalar object type.
49
49
  #
50
50
  # @param [Block] block transforms the yielded key.
51
51
  #
@@ -68,7 +68,7 @@ module OandaAPI
68
68
  # transform the block provides to the values.
69
69
  # Supports nested hashes and arrays.
70
70
  #
71
- # @param [Object] value can be a +Hash+, an +Array+ or scalar object type.
71
+ # @param [Object] value can be a `Hash`, an `Array` or scalar object type.
72
72
  #
73
73
  # @param [Object] key
74
74
  #
@@ -1,3 +1,3 @@
1
1
  module OandaAPI
2
- VERSION = "0.8.3"
2
+ VERSION = "0.9.0"
3
3
  end
@@ -0,0 +1,47 @@
1
+ require 'spec_helper'
2
+ describe "OandaAPI::ResourceBase" do
3
+ class MyClass < OandaAPI::ResourceBase
4
+ attr_accessor :webbed_feet
5
+ end
6
+
7
+ class MyCustomizedClass < OandaAPI::ResourceBase
8
+ attr_accessor :webbed_feet
9
+
10
+ def custom_attributes
11
+ super.merge(webbed_feet: "customized #{webbed_feet}")
12
+ end
13
+ end
14
+
15
+ describe "#initialize" do
16
+ it "initializes writer methods with matching attributes" do
17
+ obj = MyClass.new webbed_feet: "webbed feet"
18
+ expect(obj.webbed_feet).to eq "webbed feet"
19
+ end
20
+
21
+ it "initializes snake_case writer methods with matching camelCase attributes" do
22
+ obj = MyClass.new webbedFeet: "webbed feet"
23
+ expect(obj.webbed_feet).to eq "webbed feet"
24
+ end
25
+ end
26
+
27
+ describe "#location" do
28
+ it "sets location" do
29
+ obj = MyClass.new location: "location"
30
+ expect(obj.location).to eq "location"
31
+ end
32
+ end
33
+
34
+ describe "#to_json" do
35
+ it "serializes all of an instance's attributes" do
36
+ obj = MyClass.new webbedFeet: "webbed feet", location: "location", extraAttribute: "extra"
37
+ h = JSON.parse obj.to_json
38
+ expect(h).to include("webbed_feet" => "webbed feet", "extra_attribute" => "extra", "location" => "location")
39
+ end
40
+
41
+ it "serializes all of an instance's customized attributes" do
42
+ obj = MyCustomizedClass.new webbedFeet: "webbed feet"
43
+ h = JSON.parse obj.to_json
44
+ expect(h).to include("webbed_feet" => "customized webbed feet")
45
+ end
46
+ end
47
+ end
@@ -26,7 +26,7 @@ describe "OandaAPI::ResourceCollection" do
26
26
  describe "when the collection element is empty" do
27
27
  let(:resource_collection) { OandaAPI::ResourceCollection.new({ candles: [] }, resource_descriptor) }
28
28
  it "returns a ResourceCollection" do
29
- expect(resource_collection).to be_an(OandaAPI::ResourceCollection)
29
+ expect(resource_collection).to be_an(OandaAPI::ResourceCollection)
30
30
  end
31
31
  it "is an empty collection" do
32
32
  expect(resource_collection.empty?).to be true
@@ -106,4 +106,4 @@ describe "OandaAPI::ResourceCollection" do
106
106
  expect(resource_collection.respond_to?(:location)).to be true
107
107
  end
108
108
  end
109
- end
109
+ end
@@ -0,0 +1,132 @@
1
+ require 'spec_helper'
2
+ require 'uri'
3
+ require 'webmock/rspec'
4
+
5
+ describe "OandaAPI::Streaming::Client" do
6
+ let(:client) { OandaAPI::Streaming::Client.new(:practice, "token") }
7
+
8
+ describe "#initialize" do
9
+ it "creates a Streaming::Client instance" do
10
+ expect(client).to be_a(OandaAPI::Streaming::Client)
11
+ end
12
+
13
+ it "sets the authorization token" do
14
+ expect(client.auth_token).to eq("token")
15
+ end
16
+
17
+ it "sets the domain" do
18
+ expect(client.domain).to eq(:practice)
19
+ end
20
+
21
+ it "sets authorization headers" do
22
+ expect(client.headers).to eq(client.auth)
23
+ end
24
+
25
+ it "defaults emit_heartbeats to false" do
26
+ expect(client.emit_heartbeats?).to be false
27
+ end
28
+ end
29
+
30
+ describe "#api_uri" do
31
+ let(:client) { Struct.new(:domain) { include OandaAPI::Client }.new }
32
+ it "is domain specific" do
33
+ uris = {}
34
+ OandaAPI::DOMAINS.each do |domain|
35
+ client.domain = domain
36
+ uris[client.api_uri("/path")] = domain
37
+ end
38
+ expect(uris.size).to eq(3)
39
+ end
40
+
41
+ it "is an absolute URI" do
42
+ OandaAPI::DOMAINS.each do |domain|
43
+ client.domain = domain
44
+ uri = URI.parse client.api_uri("/path")
45
+ expect(uri.absolute?).to be true
46
+ end
47
+ end
48
+ end
49
+
50
+ describe "#auth" do
51
+ it "returns a hash with an Authorization key" do
52
+ expect(client.auth["Authorization"]).to eq("Bearer token")
53
+ end
54
+ end
55
+
56
+ describe "#domain=" do
57
+ OandaAPI::DOMAINS.each do |domain|
58
+ it "allows domain :#{domain} " do
59
+ client.domain = domain
60
+ expect(client.domain).to be(domain)
61
+ end
62
+ end
63
+
64
+ it "doesn't allow invalid domains" do
65
+ expect { client.domain = :bogus }.to raise_error(ArgumentError)
66
+ end
67
+ end
68
+
69
+ describe "#emit_heartbeats=" do
70
+ it "sets the emits heartbeats attribute" do
71
+ [true, false].each do |value|
72
+ client.emit_heartbeats = value
73
+ expect(client.emit_heartbeats?).to be value
74
+ end
75
+ end
76
+ end
77
+
78
+ describe "headers" do
79
+ it "has a reader and writer" do
80
+ client.headers = { key: "value" }
81
+ expect(client.headers).to eq(key: "value")
82
+ end
83
+ end
84
+
85
+ describe "#running?" do
86
+ it "returns true if a streaming request is running" do
87
+ events_json = <<-END
88
+ {"heartbeat":{"time":"2014-05-26T13:58:40Z"}}\r\n
89
+ {"transaction":{"id":10001}}\r\n
90
+ {"transaction":{"id":10002}}
91
+ END
92
+ stub_request(:get, "https://stream-fxpractice.oanda.com/v1/events").to_return(body: events_json, status: 200)
93
+
94
+ client = OandaAPI::Streaming::Client.new(:practice, "token")
95
+ expect(client.running?).to be false
96
+ client.events.stream do |_event, signaller|
97
+ expect(client.running?).to be true
98
+ signaller.stop!
99
+ end
100
+ end
101
+ end
102
+
103
+ describe "#stop!" do
104
+ events_json = <<-END
105
+ {"transaction":{"id": 1}}\r\n
106
+ {"transaction":{"id": 2}}
107
+ END
108
+
109
+ context "without using #stop!" do
110
+ it "emits all objects in the stream" do
111
+ stub_request(:get, "https://stream-fxpractice.oanda.com/v1/events").to_return(body: events_json, status: 200)
112
+ client = OandaAPI::Streaming::Client.new(:practice, "token")
113
+ event_ids = []
114
+ client.events.stream { |event| event_ids << event.id }
115
+ expect(event_ids).to contain_exactly(1, 2)
116
+ end
117
+ end
118
+
119
+ context "when using #stop!" do
120
+ it "terminates emitting objects in the stream" do
121
+ stub_request(:get, "https://stream-fxpractice.oanda.com/v1/events").to_return(body: events_json, status: 200)
122
+ client = OandaAPI::Streaming::Client.new(:practice, "token")
123
+ event_ids = []
124
+ client.events.stream do |event, signaller|
125
+ event_ids << event.id
126
+ signaller.stop!
127
+ end
128
+ expect(event_ids).to contain_exactly(1)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,172 @@
1
+ require 'spec_helper'
2
+ require 'uri'
3
+ require 'webmock/rspec'
4
+
5
+ describe "OandaAPI::Streaming::Request" do
6
+ let(:streaming_request) {
7
+ OandaAPI::Streaming::Request.new(uri: "https://a.url.com",
8
+ query: { account: 1234, instruments: %w[AUD_CAD AUD_CHF] },
9
+ headers: { "some-header" => "header value" })
10
+ }
11
+
12
+ describe "#initialize" do
13
+ it "creates a Streaming::Request instance" do
14
+ expect(streaming_request).to be_an(OandaAPI::Streaming::Request)
15
+ end
16
+
17
+ it "initializes the uri attribute" do
18
+ expect(streaming_request.uri.to_s).to eq "https://a.url.com?account=1234&instruments=AUD_CAD%2CAUD_CHF"
19
+ end
20
+
21
+ it "initializes the request headers attribute" do
22
+ expect(streaming_request.request["some-header"]).to eq "header value"
23
+ end
24
+
25
+ it "initializes the request's client attribute" do
26
+ client = OandaAPI::Streaming::Client.new :practice, "token"
27
+ streaming_request = OandaAPI::Streaming::Request.new(client: client,
28
+ uri: "https://a.url.com",
29
+ query: { account: 1234, instruments: %w[AUD_CAD AUD_CHF] },
30
+ headers: { "some-header" => "header value" })
31
+ expect(streaming_request.client).to eq client
32
+ end
33
+ end
34
+
35
+ describe "#client=" do
36
+ it "sets the request's client attribute" do
37
+ client = OandaAPI::Streaming::Client.new(:practice, "token")
38
+ streaming_request.client = client
39
+ expect(streaming_request.client).to eq client
40
+ end
41
+
42
+ it "fails if the value is not an Oanda::Streaming::Client" do
43
+ expect { streaming_request.client = "" }.to raise_error(ArgumentError)
44
+ end
45
+ end
46
+
47
+ describe "#stop!" do
48
+ it "sets the stop_requested signal" do
49
+ expect(streaming_request.stop_requested?).to be false
50
+ streaming_request.stop!
51
+ expect(streaming_request.stop_requested?).to be true
52
+ end
53
+ end
54
+
55
+ describe "#stream" do
56
+ it "yields all resources returned in the response stream" do
57
+ events_json = <<-END
58
+ {"transaction":{"id": 1}}\r\n
59
+ {"transaction":{"id": 2}}
60
+ END
61
+ ids = []
62
+ stub_request(:any, /\.com/).to_return(body: events_json, status: 200)
63
+ streaming_request.stream { |resource| ids << resource.id }
64
+ expect(ids).to contain_exactly(1, 2)
65
+ end
66
+
67
+ context "when emit_heartbeats? is false" do
68
+ it "ignores 'heartbeats' in the response stream" do
69
+ events_json = <<-END
70
+ {"heartbeat":{"id" : 0}}\r\n
71
+ {"transaction":{"id": 1}}\r\n
72
+ {"heartbeat":{"id" : 0}}\r\n
73
+ {"transaction":{"id": 2}}\r\n
74
+ {"heartbeat":{"id" : 0}}
75
+ END
76
+ ids = []
77
+ stub_request(:any, /\.com/).to_return(body: events_json, status: 200)
78
+ streaming_request.emit_heartbeats = false
79
+ streaming_request.stream { |resource| ids << resource.id }
80
+ expect(ids).to contain_exactly(1, 2)
81
+ end
82
+ end
83
+
84
+ context "when emit_heartbeats? is true" do
85
+ it "includes 'heartbeats' in the response stream" do
86
+ events_json = <<-END
87
+ {"heartbeat":{"id" : 10}}\r\n
88
+ {"transaction":{"id": 1}}\r\n
89
+ {"heartbeat":{"id" : 20}}\r\n
90
+ {"transaction":{"id": 2}}\r\n
91
+ {"heartbeat":{"id" : 30}}
92
+ END
93
+ transactions = []
94
+ heartbeats = 0
95
+ stub_request(:any, /\.com/).to_return(body: events_json, status: 200)
96
+ streaming_request.emit_heartbeats = true
97
+ streaming_request.stream do |resource|
98
+ if resource.is_a? OandaAPI::Resource::Heartbeat
99
+ heartbeats += 1
100
+ else
101
+ transactions << resource.id
102
+ end
103
+ end
104
+ expect(transactions).to contain_exactly(1, 2)
105
+ expect(heartbeats).to be 3
106
+ end
107
+ end
108
+
109
+ context "when a 'disconnect' is received the response stream" do
110
+ it "raises an OandaAPI::StreamingDisconnect" do
111
+ events_json = <<-END
112
+ {"transaction":{"id": 1}}\r\n
113
+ {"disconnect":{"code":60,"message":"Access Token connection limit exceeded"}}\r\n
114
+ {"transaction":{"id": 2}}
115
+ END
116
+
117
+ stub_request(:any, /\.com/).to_return(body: events_json, status: 200)
118
+ expect {
119
+ streaming_request.stream { |resource| resource }
120
+ }.to raise_error(OandaAPI::StreamingDisconnect, /connection limit exceeded/)
121
+ end
122
+ end
123
+
124
+ context "when an unknown resource type is received the response stream" do
125
+ it "raises an OandaAPI::RequestError" do
126
+ events_json = <<-END
127
+ {"transaction":{"id": 1}}\r\n
128
+ {"sponge-bob":{"is": "awesome"}}\r\n
129
+ {"transaction":{"id": 2}}
130
+ END
131
+
132
+ stub_request(:any, /\.com/).to_return(body: events_json, status: 200)
133
+ expect {
134
+ streaming_request.stream { |resource| resource }
135
+ }.to raise_error(OandaAPI::RequestError, /unknown resource/)
136
+ end
137
+ end
138
+
139
+ it "yields an object that responds to stop! and terminates streaming when called" do
140
+ events_json = <<-END
141
+ {"transaction":{"id": 1}}\r\n
142
+ {"transaction":{"id": 2}}\r\n
143
+ {"transaction":{"id": 3}}
144
+ END
145
+ stub_request(:any, /\.com/).to_return(body: events_json, status: 200)
146
+ ids = []
147
+ streaming_request.stream do |resource, signaller|
148
+ ids << resource.id
149
+ signaller.stop! if ids.size > 1
150
+ end
151
+ expect(ids).to contain_exactly(1, 2)
152
+ end
153
+
154
+ context "when the stream contains only heartbeats" do
155
+ it "terminates streaming when a stop signal is received" do
156
+ events_json = <<-END
157
+ {"heartbeat":{"id": 1}}\r\n
158
+ {"heartbeat":{"id": 2}}\r\n
159
+ {"heartbeat":{"id": 3}}
160
+ END
161
+ stub_request(:any, /\.com/).to_return(body: events_json, status: 200)
162
+ heartbeats = 0
163
+ streaming_request.emit_heartbeats = true
164
+ streaming_request.stream do |_resource, signaller|
165
+ heartbeats += 1
166
+ signaller.stop!
167
+ end
168
+ expect(heartbeats).to eq 1
169
+ end
170
+ end
171
+ end
172
+ end