oanda_api 0.8.3 → 0.9.0

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