sequence-sdk 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.
@@ -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