sequence-sdk 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,206 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'net/https'
4
+ require 'openssl'
5
+ require 'thread'
6
+
7
+ require_relative './batch_response'
8
+ require_relative './errors'
9
+ require_relative './version'
10
+
11
+ module Chain
12
+ class Connection
13
+
14
+ # Parameters to the retry exponential backoff function.
15
+ MAX_RETRIES = 10
16
+ RETRY_BASE_DELAY_MS = 40
17
+ RETRY_MAX_DELAY_MS = 4000
18
+
19
+ NETWORK_ERRORS = [
20
+ InvalidRequestIDError,
21
+ SocketError,
22
+ EOFError,
23
+ IOError,
24
+ Timeout::Error,
25
+ Errno::ECONNABORTED,
26
+ Errno::ECONNRESET,
27
+ Errno::ETIMEDOUT,
28
+ Errno::EHOSTUNREACH,
29
+ Errno::ECONNREFUSED,
30
+ ]
31
+
32
+ def initialize(opts)
33
+ @opts = opts
34
+ @url = URI(@opts[:url])
35
+ @access_token = @opts[:access_token] || @url.userinfo
36
+ @http_mutex = Mutex.new
37
+ end
38
+
39
+ # Returns a copy of the configuration options
40
+ def opts
41
+ @opts.dup
42
+ end
43
+
44
+ def request(path, body = {})
45
+ _request_with_retries(path, body)[:body]
46
+ end
47
+
48
+ def batch_request(path, body = {}, &translate)
49
+ res = _request_with_retries(path, body)
50
+ body = res[:body]
51
+ response = res[:response]
52
+
53
+ successes = {}
54
+ errors = {}
55
+
56
+ body.each_with_index do |item, i|
57
+ if !!item['code']
58
+ errors[i] = APIError.new(item, response)
59
+ else
60
+ if translate
61
+ successes[i] = translate.call(item)
62
+ else
63
+ successes[i] = item
64
+ end
65
+ end
66
+ end
67
+
68
+ BatchResponse.new(
69
+ successes: successes,
70
+ errors: errors,
71
+ response: response,
72
+ )
73
+ end
74
+
75
+ def singleton_batch_request(path, body = {}, &translate)
76
+ batch = batch_request(path, body, &translate)
77
+
78
+ if batch.size != 1
79
+ raise "Invalid response, expected a single response object but got #{batch.items.size}"
80
+ end
81
+
82
+ raise batch.errors.values.first if batch.errors.size == 1
83
+
84
+ batch.successes.values.first
85
+ end
86
+
87
+ private
88
+
89
+ def _request_with_retries(path, body)
90
+ attempts = 0
91
+
92
+ begin
93
+ attempts += 1
94
+
95
+ # If this is a retry and not the first attempt, sleep before making the
96
+ # retry request.
97
+ sleep(backoff_delay(attempts)) if attempts > 1
98
+
99
+ _single_request(path, body)
100
+ rescue *NETWORK_ERRORS => e
101
+ raise e if attempts > MAX_RETRIES
102
+ retry
103
+ rescue APIError => e
104
+ raise e if attempts > MAX_RETRIES
105
+ retry if e.retriable?
106
+ raise e
107
+ end
108
+ end
109
+
110
+ def _single_request(path, body)
111
+ @http_mutex.synchronize do
112
+ # Timeout configuration
113
+ [:open_timeout, :read_timeout, :ssl_timeout].each do |k|
114
+ next unless @opts.key?(k)
115
+ http.send "#{k}=", @opts[k]
116
+ end
117
+
118
+ full_path = @url.request_uri.chomp('/')
119
+ full_path += (path[0] == '/') ? path : '/' + path
120
+
121
+ req = Net::HTTP::Post.new(full_path)
122
+ req['Accept'] = 'application/json'
123
+ req['Content-Type'] = 'application/json'
124
+ req['User-Agent'] = 'chain-sdk-ruby/' + Chain::VERSION
125
+ req.body = JSON.dump(body)
126
+
127
+ if @access_token
128
+ user, pass = @access_token.split(':')
129
+ req.basic_auth(user, pass)
130
+ end
131
+
132
+ response = http.request(req)
133
+
134
+ req_id = response['Chain-Request-ID']
135
+ unless req_id.is_a?(String) && req_id.size > 0
136
+ raise InvalidRequestIDError.new(response)
137
+ end
138
+
139
+ status = Integer(response.code)
140
+ parsed_body = nil
141
+
142
+ if status != 204 # No Content
143
+ begin
144
+ parsed_body = JSON.parse(response.body)
145
+ rescue JSON::JSONError
146
+ raise JSONError.new(req_id, response)
147
+ end
148
+ end
149
+
150
+ if status / 100 != 2
151
+ klass = status == 401 ? UnauthorizedError : APIError
152
+ raise klass.new(parsed_body, response)
153
+ end
154
+
155
+ {body: parsed_body, response: response}
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ MILLIS_TO_SEC = 0.001
162
+
163
+ def backoff_delay(attempt)
164
+ max = RETRY_BASE_DELAY_MS * 2**(attempt-1)
165
+ max = [max, RETRY_MAX_DELAY_MS].min
166
+ millis = rand(max) + 1
167
+ millis * MILLIS_TO_SEC
168
+ end
169
+
170
+ def http
171
+ return @http if @http
172
+
173
+ args = [@url.host, @url.port]
174
+
175
+ # Proxy configuration
176
+ if @opts.key?(:proxy_addr)
177
+ args += [@opts[:proxy_addr], @opts[:proxy_port]]
178
+ if @opts.key?(:proxy_user)
179
+ args += [@opts[:proxy_user], @opts[:proxy_pass]]
180
+ end
181
+ end
182
+
183
+ @http = Net::HTTP.new(*args)
184
+
185
+ @http.set_debug_output($stdout) if ENV['DEBUG']
186
+ if @url.scheme == 'https'
187
+ @http.use_ssl = true
188
+ @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
189
+ if @opts.key?(:ssl_params)
190
+ ssl_params = @opts[:ssl_params]
191
+ if ssl_params.key?(:ca_file)
192
+ @http.ca_file = ssl_params[:ca_file]
193
+ end
194
+ if ssl_params.key?(:cert)
195
+ @http.cert = ssl_params[:cert]
196
+ end
197
+ if ssl_params.key?(:key)
198
+ @http.key = ssl_params[:key]
199
+ end
200
+ end
201
+ end
202
+
203
+ @http
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,3 @@
1
+ module Chain
2
+ MAX_BLOCK_HEIGHT = (2 ** 63) - 1
3
+ end
@@ -0,0 +1,14 @@
1
+ require 'securerandom'
2
+
3
+ require_relative './client_module'
4
+
5
+ module Chain
6
+ class DevUtils
7
+ class ClientModule < Chain::ClientModule
8
+ # Deletes all data in the ledger. (development ledgers only)
9
+ def reset
10
+ client.conn.request('/reset', client_token: SecureRandom.uuid)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,80 @@
1
+ module Chain
2
+
3
+ # Base class for all errors raised by the Chain SDK.
4
+ class BaseError < StandardError; end
5
+
6
+ # InvalidRequestIDError arises when an HTTP response is received, but it does
7
+ # not contain headers that are included in all Chain API responses. This
8
+ # could arise due to a badly-configured proxy, or other upstream network
9
+ # issues.
10
+ class InvalidRequestIDError < BaseError
11
+ attr_accessor :response
12
+
13
+ def initialize(response)
14
+ super "Response HTTP header field Chain-Request-ID is unset. There may be network issues. Please check your local network settings."
15
+ self.response = response
16
+ end
17
+ end
18
+
19
+ # JSONError should be very rare, and will only arise if there is a bug in the
20
+ # Chain API, or if the upstream server is spoofing common Chain API response
21
+ # headers.
22
+ class JSONError < BaseError
23
+ attr_accessor :request_id
24
+ attr_accessor :response
25
+
26
+ def initialize(request_id, response)
27
+ super "Error decoding JSON response. Request-ID: #{request_id}"
28
+ self.request_id = request_id
29
+ self.response = response
30
+ end
31
+ end
32
+
33
+ # APIError describes errors that are codified by the Chain API. They have
34
+ # an error code, a message, and an optional detail field that provides
35
+ # additional context for the error.
36
+ class APIError < BaseError
37
+ RETRIABLE_STATUS_CODES = [
38
+ 408, # Request Timeout
39
+ 429, # Too Many Requests
40
+ 500, # Internal Server Error
41
+ 502, # Bad Gateway
42
+ 503, # Service Unavailable
43
+ 504, # Gateway Timeout
44
+ 509, # Bandwidth Limit Exceeded
45
+ ]
46
+
47
+ attr_accessor :code, :chain_message, :detail, :data, :temporary, :request_id, :response
48
+
49
+ def initialize(body, response)
50
+ self.code = body['code']
51
+ self.chain_message = body['message']
52
+ self.detail = body['detail']
53
+ self.temporary = body['temporary']
54
+
55
+ self.response = response
56
+ self.request_id = response['Chain-Request-ID'] if response
57
+
58
+ super self.class.format_error_message(code, chain_message, detail, request_id)
59
+ end
60
+
61
+ def retriable?
62
+ temporary || (response && RETRIABLE_STATUS_CODES.include?(Integer(response.code)))
63
+ end
64
+
65
+ def self.format_error_message(code, message, detail, request_id)
66
+ tokens = []
67
+ tokens << "Code: #{code}" if code.is_a?(String) && code.size > 0
68
+ tokens << "Message: #{message}"
69
+ tokens << "Detail: #{detail}" if detail.is_a?(String) && detail.size > 0
70
+ tokens << "Request-ID: #{request_id}"
71
+ tokens.join(' ')
72
+ end
73
+ end
74
+
75
+ # UnauthorizedError is a special case of APIError, and is raised when the
76
+ # response status code is 401. This is a common error case, so a discrete
77
+ # exception type is provided for convenience.
78
+ class UnauthorizedError < APIError; end
79
+
80
+ end
data/lib/chain/key.rb ADDED
@@ -0,0 +1,46 @@
1
+ require_relative './client_module'
2
+ require_relative './connection'
3
+ require_relative './query'
4
+ require_relative './response_object'
5
+
6
+ module Chain
7
+ class Key < ResponseObject
8
+ # @!attribute [r] alias
9
+ # User specified, unique identifier of the key.
10
+ # @return [String]
11
+ attrib :alias
12
+
13
+ # @!attribute [r] id
14
+ # Unique identifier of the key, based on the public key material itself.
15
+ # @return [String]
16
+ attrib :id
17
+
18
+ class ClientModule < Chain::ClientModule
19
+
20
+ # Creates a key object.
21
+ # @param [Hash] opts Parameters for MockHSM key creation.
22
+ # @option opts [String] alias User specified, unique identifier.
23
+ # @return [Key]
24
+ def create(opts = {})
25
+ Key.new(client.conn.request('create-key', opts))
26
+ end
27
+
28
+ # @param [Hash] opts Filtering information
29
+ # @option opts [Array<String>] aliases Optional list of requested aliases, max 200.
30
+ # @return [Query]
31
+ def query(opts = {})
32
+ Query.new(client, opts)
33
+ end
34
+ end
35
+
36
+ class Query < Chain::Query
37
+ def fetch(query)
38
+ client.conn.request('list-keys', query)
39
+ end
40
+
41
+ def translate(obj)
42
+ Key.new(obj)
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/chain/page.rb ADDED
@@ -0,0 +1,25 @@
1
+ require_relative './response_object'
2
+
3
+ module Chain
4
+ class Page < ResponseObject
5
+ # @!attribute [r] items
6
+ # List of items.
7
+ # @return [Array]
8
+ attrib :items
9
+
10
+ # @!attribute [r] next
11
+ # Query object to request next page of items
12
+ # @return [Hash]
13
+ attrib :next
14
+
15
+ # @!attribute [r] last_page
16
+ # Indicator of whether there are more pages to load
17
+ # @return [Boolean]
18
+ attrib :last_page
19
+
20
+ def initialize(raw_attribs, translate)
21
+ super(raw_attribs)
22
+ @items = @items.map { |i| translate.call(i) }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,79 @@
1
+ require_relative './page'
2
+
3
+ module Chain
4
+ class Query
5
+ include ::Enumerable
6
+
7
+ # @return [Client]
8
+ attr_reader :client
9
+
10
+ # @return [Hash]
11
+ attr_reader :query
12
+
13
+ def initialize(client, query = {})
14
+ @client = client
15
+ @query = query
16
+ end
17
+
18
+ # Iterate through objects in response, fetching the next page of results
19
+ # from the API as needed.
20
+ #
21
+ # Implements required method
22
+ # {https://ruby-doc.org/core/Enumerable.html Enumerable#each}.
23
+ # @return [void]
24
+ def each
25
+ pages.each do |page|
26
+ page.items.each do |item, index|
27
+ yield item
28
+ end
29
+ end
30
+ end
31
+
32
+ def pages
33
+ PageQuery.new(client, query, method(:fetch), method(:translate))
34
+ end
35
+
36
+ # @abstract
37
+ def fetch(query)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ # Overwrite to translate API response data to a different Ruby object.
42
+ # @abstract
43
+ def translate(response_object)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ alias_method :all, :to_a
48
+
49
+ class PageQuery
50
+ include ::Enumerable
51
+
52
+ def initialize(client, query, fetch, translate)
53
+ @client = client
54
+ @query = query
55
+ @fetch = fetch
56
+ @translate = translate
57
+ end
58
+
59
+ def each
60
+ page = nil
61
+
62
+ loop do
63
+ page = Page.new(@fetch.call(@query), @translate)
64
+ @query = page.next
65
+
66
+ yield page
67
+
68
+ break if page.last_page
69
+
70
+ # The second predicate (empty?) *should* be redundant, but we check it
71
+ # anyway as a defensive measure.
72
+ break if page.items.empty?
73
+ end
74
+ end
75
+
76
+ alias_method :all, :to_a
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,116 @@
1
+ require 'json'
2
+ require 'time'
3
+
4
+ module Chain
5
+ class ResponseObject
6
+ def initialize(raw_attribs)
7
+ raw_attribs.each do |k, v|
8
+ next unless self.class.has_attrib?(k)
9
+ self[k] = self.class.translate(k, v) unless v.nil?
10
+ end
11
+ end
12
+
13
+ def to_h
14
+ self.class.attrib_opts.keys.reduce({}) do |memo, name|
15
+ memo[name] = instance_variable_get("@#{name}")
16
+ memo
17
+ end
18
+ end
19
+
20
+ def to_json(opts = nil)
21
+ h = to_h.reduce({}) do |memo, (k, v)|
22
+ memo[k] = self.class.detranslate(k, v)
23
+ memo
24
+ end
25
+
26
+ h.to_json
27
+ end
28
+
29
+ def [](attrib_name)
30
+ attrib_name = attrib_name.to_sym
31
+ raise KeyError.new("key not found: #{attrib_name}") unless self.class.attrib_opts.key?(attrib_name)
32
+
33
+ instance_variable_get "@#{attrib_name}"
34
+ end
35
+
36
+ def []=(attrib_name, value)
37
+ attrib_name = attrib_name.to_sym
38
+ raise KeyError.new("key not found: #{attrib_name}") unless self.class.attrib_opts.key?(attrib_name)
39
+
40
+ instance_variable_set "@#{attrib_name}", value
41
+ end
42
+
43
+ # @!visibility private
44
+ def self.attrib_opts
45
+ @attrib_opts ||= {}
46
+ end
47
+
48
+ # @!visibility private
49
+ def self.attrib(attrib_name, opts = {}, &translate)
50
+ opts[:translate] = translate
51
+ attrib_opts[attrib_name.to_sym] = opts
52
+ attr_accessor attrib_name
53
+ end
54
+
55
+ # @!visibility private
56
+ def self.has_attrib?(attrib_name)
57
+ attrib_opts.key?(attrib_name.to_sym)
58
+ end
59
+
60
+ # @!visibility private
61
+ def self.translate(attrib_name, raw_value)
62
+ attrib_name = attrib_name.to_sym
63
+ opts = attrib_opts[attrib_name]
64
+
65
+ return Time.parse(raw_value) if opts[:rfc3339_time]
66
+ return raw_value if opts[:translate].nil?
67
+
68
+ begin
69
+ opts[:translate].call raw_value
70
+ rescue => e
71
+ raise TranslateError.new(attrib_name, raw_value, e)
72
+ end
73
+ end
74
+
75
+ # @!visibility private
76
+ def self.detranslate(attrib_name, raw_value)
77
+ opts = attrib_opts.fetch(attrib_name, {})
78
+
79
+ if opts[:rfc3339_time]
80
+ begin
81
+ return raw_value.to_datetime.rfc3339
82
+ rescue => e
83
+ raise DetranslateError.new(attrib_name, raw_value, e)
84
+ end
85
+ end
86
+
87
+ raw_value
88
+ end
89
+
90
+ class TranslateError < StandardError
91
+ attr_reader :attrib_name
92
+ attr_reader :raw_value
93
+ attr_reader :source
94
+
95
+ def initialize(attrib_name, raw_value, source)
96
+ super "Error translating attrib #{attrib_name}: #{source}"
97
+ @attrib_name = attrib_name
98
+ @raw_value = raw_value
99
+ @source = source
100
+ end
101
+ end
102
+
103
+ class DetranslateError < StandardError
104
+ attr_reader :attrib_name
105
+ attr_reader :raw_value
106
+ attr_reader :source
107
+
108
+ def initialize(attrib_name, raw_value, source)
109
+ super "Error de-translating attrib #{attrib_name}: #{source}"
110
+ @attrib_name = attrib_name
111
+ @raw_value = raw_value
112
+ @source = source
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,31 @@
1
+ require_relative './client_module'
2
+ require_relative './response_object'
3
+ require_relative './query'
4
+
5
+ module Chain
6
+ class Stats < ResponseObject
7
+
8
+ # @!attribute [r] asset_count
9
+ # Total number of assets in the ledger.
10
+ # @return [Integer]
11
+ attrib :asset_count
12
+
13
+ # @!attribute [r] account_count
14
+ # Total number of accounts in the ledger.
15
+ # @return [Integer]
16
+ attrib :account_count
17
+
18
+ # @!attribute [r] tx_count
19
+ # Total number of transactions in the ledger.
20
+ # @return [Integer]
21
+ attrib :tx_count
22
+
23
+ class ClientModule < Chain::ClientModule
24
+ # @return [Stats]
25
+ def get
26
+ Stats.new(client.conn.request('stats'))
27
+ end
28
+ end
29
+
30
+ end
31
+ end