zaius 0.1.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.
data/lib/zaius/util.rb ADDED
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Zaius
6
+ module Util
7
+ # Options that a user is allowed to specify.
8
+ OPTS_USER_SPECIFIED = Set[
9
+ :api_key,
10
+ ].freeze
11
+
12
+ # Options that should be copyable from one ZaiusObject to another
13
+ # including options that may be internal.
14
+ OPTS_COPYABLE = (
15
+ OPTS_USER_SPECIFIED + Set[:api_base]
16
+ ).freeze
17
+
18
+ # Options that should be persisted between API requests. This includes
19
+ # client, which is an object containing an HTTP client to reuse.
20
+ OPTS_PERSISTABLE = (
21
+ OPTS_USER_SPECIFIED + Set[:client]
22
+ ).freeze
23
+
24
+ def self.objects_to_ids(h)
25
+ case h
26
+ when APIResource
27
+ h.id
28
+ when Hash
29
+ res = {}
30
+ h.each { |k, v| res[k] = objects_to_ids(v) unless v.nil? }
31
+ res
32
+ when Array
33
+ h.map { |v| objects_to_ids(v) }
34
+ else
35
+ h
36
+ end
37
+ end
38
+
39
+ def self.object_classes
40
+ @object_classes ||= {
41
+ ListObject::OBJECT_NAME => ListObject,
42
+ Subscription::OBJECT_NAME => Subscription,
43
+ }
44
+ end
45
+
46
+ # Converts a hash of fields or an array of hashes into a +ZaiusObject+ or
47
+ # array of +ZaiusObject+s. These new objects will be created as a concrete
48
+ # type as dictated by their `object` field (e.g. an `object` value of
49
+ # `charge` would create an instance of +Charge+), but if `object` is not
50
+ # present or of an unknown type, the newly created instance will fall back
51
+ # to being a +ZaiusObject+.
52
+ #
53
+ # ==== Attributes
54
+ #
55
+ # * +data+ - Hash of fields and values to be converted into a ZaiusObject.
56
+ # * +opts+ - Options for +ZaiusObject+ like an API key that will be reused
57
+ # on subsequent API calls.
58
+ def self.convert_to_zaius_object(data, opts = {})
59
+ case data
60
+ when Array
61
+ data.map { |i| convert_to_zaius_object(i, opts) }
62
+ when Hash
63
+ # Try converting to a known object class. If none available, fall back to generic ZaiusObject
64
+ object_classes.fetch(data[:object], ZaiusObject).construct_from(data, opts)
65
+ else
66
+ data
67
+ end
68
+ end
69
+
70
+ def self.log_info(message, data = {})
71
+ if !Zaius.logger.nil? ||
72
+ !Zaius.log_level.nil? && Zaius.log_level <= Zaius::LEVEL_INFO
73
+ log_internal(message, data, color: :cyan, out: $stderr)
74
+ end
75
+ end
76
+
77
+ def self.log_debug(message, data = {})
78
+ if !Zaius.logger.nil? ||
79
+ !Zaius.log_level.nil? && Zaius.log_level <= Zaius::LEVEL_DEBUG
80
+ log_internal(message, data, color: :cyan, out: $stderr)
81
+ end
82
+ end
83
+
84
+ def self.log_error(message, data = {})
85
+ if !Zaius.logger.nil? ||
86
+ !Zaius.log_level.nil? && Zaius.log_level <= Zaius::LEVEL_ERROR
87
+ log_internal(message, data, color: :cyan, out: $stderr)
88
+ end
89
+ end
90
+
91
+ def self.symbolize_names(object)
92
+ case object
93
+ when Hash
94
+ new_hash = {}
95
+ object.each do |key, value|
96
+ key = (begin
97
+ key.to_sym
98
+ rescue StandardError
99
+ key
100
+ end) || key
101
+ new_hash[key] = symbolize_names(value)
102
+ end
103
+ new_hash
104
+ when Array
105
+ object.map { |value| symbolize_names(value) }
106
+ else
107
+ object
108
+ end
109
+ end
110
+
111
+ # Encodes a hash of parameters in a way that's suitable for use as query
112
+ # parameters in a URI or as form parameters in a request body. This mainly
113
+ # involves escaping special characters from parameter keys and values (e.g.
114
+ # `&`).
115
+ def self.encode_parameters(params)
116
+ Util.flatten_params(params)
117
+ .map { |k, v| "#{url_encode(k)}=#{url_encode(v)}" }.join("&")
118
+ end
119
+
120
+ # Encodes a string in a way that makes it suitable for use in a set of
121
+ # query parameters in a URI or in a set of form parameters in a request
122
+ # body.
123
+ def self.url_encode(key)
124
+ CGI.escape(key.to_s).
125
+ # Don't use strict form encoding by changing the square bracket control
126
+ # characters back to their literals. This is fine by the server, and
127
+ # makes these parameter strings easier to read.
128
+ gsub("%5B", "[").gsub("%5D", "]")
129
+ end
130
+
131
+ def self.flatten_params(params, parent_key = nil)
132
+ result = []
133
+
134
+ # do not sort the final output because arrays (and arrays of hashes
135
+ # especially) can be order sensitive, but do sort incoming parameters
136
+ params.each do |key, value|
137
+ calculated_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
138
+ if value.is_a?(Hash)
139
+ result += flatten_params(value, calculated_key)
140
+ elsif value.is_a?(Array)
141
+ result += flatten_params_array(value, calculated_key)
142
+ else
143
+ result << [calculated_key, value]
144
+ end
145
+ end
146
+
147
+ result
148
+ end
149
+
150
+ def self.flatten_params_array(value, calculated_key)
151
+ result = []
152
+ value.each_with_index do |elem, i|
153
+ if elem.is_a?(Hash)
154
+ result += flatten_params(elem, "#{calculated_key}[#{i}]")
155
+ elsif elem.is_a?(Array)
156
+ result += flatten_params_array(elem, calculated_key)
157
+ else
158
+ result << ["#{calculated_key}[#{i}]", elem]
159
+ end
160
+ end
161
+ result
162
+ end
163
+
164
+ def self.normalize_id(id)
165
+ if id.is_a?(Hash) # overloaded id
166
+ params_hash = id.dup
167
+ id = params_hash.delete(:id)
168
+ else
169
+ params_hash = {}
170
+ end
171
+ [id, params_hash]
172
+ end
173
+
174
+ # The secondary opts argument can either be a string or hash
175
+ # Turn this value into an api_key and a set of headers
176
+ def self.normalize_opts(opts)
177
+ case opts
178
+ when String
179
+ { api_key: opts }
180
+ when Hash
181
+ check_api_key!(opts.fetch(:api_key)) if opts.key?(:api_key)
182
+ opts.clone
183
+ else
184
+ raise TypeError, "normalize_opts expects a string or a hash"
185
+ end
186
+ end
187
+
188
+ def self.check_string_argument!(key)
189
+ raise TypeError, "argument must be a string" unless key.is_a?(String)
190
+ key
191
+ end
192
+
193
+ def self.check_api_key!(key)
194
+ raise TypeError, "api_key must be a string" unless key.is_a?(String)
195
+ key
196
+ end
197
+
198
+ # Normalizes header keys so that they're all lower case and each
199
+ # hyphen-delimited section starts with a single capitalized letter. For
200
+ # example, `request-id` becomes `Request-Id`. This is useful for extracting
201
+ # certain key values when the user could have set them with a variety of
202
+ # diffent naming schemes.
203
+ def self.normalize_headers(headers)
204
+ headers.each_with_object({}) do |(k, v), new_headers|
205
+ k = k.to_s.tr("_", "-") if k.is_a?(Symbol)
206
+ k = k.split("-").reject(&:empty?).map(&:capitalize).join("-")
207
+
208
+ new_headers[k] = v
209
+ end
210
+ end
211
+
212
+ # Constant time string comparison to prevent timing attacks
213
+ # Code borrowed from ActiveSupport
214
+ def self.secure_compare(a, b)
215
+ return false unless a.bytesize == b.bytesize
216
+
217
+ l = a.unpack "C#{a.bytesize}"
218
+
219
+ res = 0
220
+ b.each_byte { |byte| res |= byte ^ l.shift }
221
+ res.zero?
222
+ end
223
+
224
+ #
225
+ # private
226
+ #
227
+
228
+ COLOR_CODES = {
229
+ black: 0, light_black: 60,
230
+ red: 1, light_red: 61,
231
+ green: 2, light_green: 62,
232
+ yellow: 3, light_yellow: 63,
233
+ blue: 4, light_blue: 64,
234
+ magenta: 5, light_magenta: 65,
235
+ cyan: 6, light_cyan: 66,
236
+ white: 7, light_white: 67,
237
+ default: 9,
238
+ }.freeze
239
+ private_constant :COLOR_CODES
240
+
241
+ # Uses an ANSI escape code to colorize text if it's going to be sent to a
242
+ # TTY.
243
+ def self.colorize(val, color, isatty)
244
+ return val unless isatty
245
+
246
+ mode = 0 # default
247
+ foreground = 30 + COLOR_CODES.fetch(color)
248
+ background = 40 + COLOR_CODES.fetch(:default)
249
+
250
+ "\033[#{mode};#{foreground};#{background}m#{val}\033[0m"
251
+ end
252
+ private_class_method :colorize
253
+
254
+ # Turns an integer log level into a printable name.
255
+ def self.level_name(level)
256
+ level
257
+ end
258
+ private_class_method :level_name
259
+
260
+ # TODO: Make these named required arguments when we drop support for Ruby
261
+ # 2.0.
262
+ def self.log_internal(message, data = {}, color: nil, level: nil, logger: nil, out: nil)
263
+ data_str = data.reject { |_k, v| v.nil? }
264
+ .map do |(k, v)|
265
+ format("%s=%s", colorize(k, color, logger.nil? && !out.nil? && out.isatty), wrap_logfmt_value(v))
266
+ end.join(" ")
267
+
268
+ if !logger.nil?
269
+ # the library's log levels are mapped to the same values as the
270
+ # standard library's logger
271
+ logger.log(level,
272
+ format("message=%s %s", wrap_logfmt_value(message), data_str))
273
+ elsif out.isatty
274
+ out.puts format("%s %s %s", nil, message, data_str)
275
+ else
276
+ out.puts format("message=%s level=%s %s", wrap_logfmt_value(message), level_name(level), data_str)
277
+ end
278
+ end
279
+ private_class_method :log_internal
280
+
281
+ # Wraps a value in double quotes if it looks sufficiently complex so that
282
+ # it can be read by logfmt parsers.
283
+ def self.wrap_logfmt_value(val)
284
+ # If value is any kind of number, just allow it to be formatted directly
285
+ # to a string (this will handle integers or floats).
286
+ return val if val.is_a?(Numeric)
287
+
288
+ # Hopefully val is a string, but protect in case it's not.
289
+ val = val.to_s
290
+
291
+ if %r{[^\w\-/]} =~ val
292
+ # If the string contains any special characters, escape any double
293
+ # quotes it has, remove newlines, and wrap the whole thing in quotes.
294
+ format(%("%s"), val.gsub('"', '\"').delete("\n"))
295
+ else
296
+ # Otherwise use the basic value if it looks like a standard set of
297
+ # characters (and allow a few special characters like hyphens, and
298
+ # slashes)
299
+ val
300
+ end
301
+ end
302
+ private_class_method :wrap_logfmt_value
303
+ end
304
+ end
@@ -0,0 +1,3 @@
1
+ module Zaius
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,273 @@
1
+ module Zaius
2
+ class ZaiusClient
3
+ attr_accessor :conn
4
+
5
+ # Initializes a new ZaiusClient. Expects a Faraday connection object, and
6
+ # uses a default connection unless one is passed.
7
+ def initialize(conn = nil)
8
+ self.conn = conn || self.class.default_conn
9
+ end
10
+
11
+ def self.default_client
12
+ Thread.current[:zaius_client_default_client] ||= ZaiusClient.new(default_conn)
13
+ end
14
+
15
+ def self.active_client
16
+ Thread.current[:zaius_client] || default_client
17
+ end
18
+
19
+ # A default Faraday connection to be used when one isn't configured. This
20
+ # object should never be mutated, and instead instantiating your own
21
+ # connection and wrapping it in a ZaiusClient object should be preferred.
22
+ def self.default_conn
23
+ # We're going to keep connections around so that we can take advantage
24
+ # of connection re-use, so make sure that we have a separate connection
25
+ # object per thread.
26
+ Thread.current[:zaius_client_default_conn] ||= begin
27
+ conn = Faraday.new do |c|
28
+ c.use Faraday::Request::Multipart
29
+ c.use Faraday::Request::UrlEncoded
30
+ c.use Faraday::Response::RaiseError
31
+ c.adapter Faraday.default_adapter
32
+ end
33
+
34
+ conn
35
+ end
36
+ end
37
+
38
+ def request_headers(api_key, method)
39
+ user_agent = "Zaius/v1 RubyBindings/#{Zaius::VERSION}"
40
+
41
+ headers = {
42
+ "User-Agent" => user_agent,
43
+ "x-api-key" => api_key,
44
+ "Content-Type" => "application/x-www-form-urlencoded",
45
+ }
46
+
47
+ headers
48
+ end
49
+
50
+ def execute_request(method, path,
51
+ api_base: nil, api_key: nil, headers: {}, params: {})
52
+
53
+ api_base ||= Zaius.api_base
54
+ api_key ||= Zaius.api_key
55
+
56
+ check_api_key!(api_key)
57
+
58
+ url = api_url(path, api_base)
59
+
60
+ body = nil
61
+ query_params = nil
62
+
63
+ case method.to_s.downcase.to_sym
64
+ when :get, :head, :delete
65
+ query_params = params
66
+ else
67
+ body = params.to_json
68
+ end
69
+
70
+ # This works around an edge case where we end up with both query
71
+ # parameters in `query_params` and query parameters that are appended
72
+ # onto the end of the given path. In this case, Faraday will silently
73
+ # discard the URL's parameters which may break a request.
74
+ #
75
+ # Here we decode any parameters that were added onto the end of a path
76
+ # and add them to `query_params` so that all parameters end up in one
77
+ # place and all of them are correctly included in the final request.
78
+ # u = URI.parse(path)
79
+ # unless u.query.nil?
80
+ # query_params ||= {}
81
+ # query_params = Hash[URI.decode_www_form(u.query)].merge(query_params)
82
+
83
+ # # Reset the path minus any query parameters that were specified.
84
+ # path = u.path
85
+ # end
86
+
87
+ headers = request_headers(api_key, method)
88
+ .update(headers)
89
+
90
+ # stores information on the request we're about to make so that we don't
91
+ # have to pass as many parameters around for logging.
92
+ context = RequestLogContext.new
93
+ context.api_key = api_key
94
+ context.body = body
95
+ context.method = method
96
+ context.path = path
97
+ context.query_params = query_params ? Util.encode_parameters(query_params) : nil
98
+
99
+ http_resp = execute_request_with_rescues(api_base, context) do
100
+ conn.run_request(method, url, body, headers) do |req|
101
+ req.params = query_params unless query_params.nil?
102
+ end
103
+ end
104
+
105
+ begin
106
+ resp = ZaiusResponse.from_faraday_response(http_resp)
107
+ rescue JSON::ParserError
108
+ raise general_api_error(http_resp.status, http_resp.body)
109
+ end
110
+
111
+ # Allows ZaiusClient#request to return a response object to a caller.
112
+ @last_response = resp
113
+ [resp, api_key]
114
+ end
115
+
116
+ def execute_request_with_rescues(api_base, context)
117
+ num_retries = 0
118
+ begin
119
+ request_start = Time.now
120
+ log_request(context, num_retries)
121
+ resp = yield
122
+ context = context.dup_from_response(resp)
123
+ log_response(context, request_start, resp.status, resp.body)
124
+
125
+ # We rescue all exceptions from a request so that we have an easy spot to
126
+ # implement our retry logic across the board. We'll re-raise if it's a type
127
+ # of exception that we didn't expect to handle.
128
+ rescue StandardError => e
129
+ # If we modify context we copy it into a new variable so as not to
130
+ # taint the original on a retry.
131
+ error_context = context
132
+
133
+ if e.respond_to?(:response) && e.response
134
+ error_context = context.dup_from_response(e.response)
135
+ log_response(error_context, request_start,
136
+ e.response[:status], e.response[:body])
137
+ else
138
+ log_response_error(error_context, request_start, e)
139
+ end
140
+
141
+ case e
142
+ when Faraday::ClientError
143
+ if e.response
144
+ handle_error_response(e.response, error_context)
145
+ else
146
+ handle_network_error(e, error_context, num_retries, api_base)
147
+ end
148
+
149
+ # Only handle errors when we know we can do so, and re-raise otherwise.
150
+ # This should be pretty infrequent.
151
+ else
152
+ raise
153
+ end
154
+ end
155
+
156
+ resp
157
+ end
158
+
159
+ def handle_error_response(http_resp, context)
160
+ begin
161
+ resp = ZaiusResponse.from_faraday_hash(http_resp)
162
+ error_data = resp.data[:title]
163
+
164
+ raise ZaiusError, "Indeterminate error" if resp.data[:title].nil?
165
+ rescue JSON::ParserError, ZaiusError
166
+ raise general_api_error(http_resp[:status], http_resp[:body])
167
+ end
168
+
169
+ error = specific_api_error(resp, error_data, context)
170
+
171
+ error.response = resp
172
+ raise(error)
173
+ end
174
+
175
+ def general_api_error(status, body)
176
+ ZaiusError.new("Invalid response object from API: #{body.inspect} " \
177
+ "(HTTP response code was #{status})",
178
+ http_status: status, http_body: body)
179
+ end
180
+
181
+ def specific_api_error(response, error_data, context)
182
+ response_data = response.data
183
+
184
+ APIError.new(title: response_data[:title], http_status: response.http_status, detail: response_data[:detail])
185
+ end
186
+
187
+ def log_response(context, request_start, status, body)
188
+ Util.log_info("Response from Zaius",
189
+ account: context.account,
190
+ api_version: context.api_version,
191
+ elapsed: Time.now - request_start,
192
+ method: context.method,
193
+ path: context.path,
194
+ request_id: context.request_id,
195
+ url: context.url,
196
+ status: status)
197
+ Util.log_debug("Response details",
198
+ body: body,
199
+ request_id: context.request_id)
200
+ end
201
+
202
+ def log_request(context, num_retries)
203
+ Util.log_info("Request to Zaius",
204
+ account: context.account,
205
+ api_version: context.api_version,
206
+ method: context.method,
207
+ num_retries: num_retries,
208
+ path: context.path)
209
+ Util.log_debug("Request details",
210
+ body: context.body,
211
+ query_params: context.query_params)
212
+ end
213
+ private :log_request
214
+
215
+ def log_response_error(context, request_start, e)
216
+ Util.log_error("Request error",
217
+ elapsed: Time.now - request_start,
218
+ error_message: e.message,
219
+ method: context.method,
220
+ path: context.path)
221
+ end
222
+
223
+ def api_url(url = "", api_base = nil)
224
+ (api_base || Zaius.api_base) + url
225
+ end
226
+
227
+ def check_api_key!(api_key)
228
+ unless api_key
229
+ raise AuthenticationError, "No API key provided. " \
230
+ 'Set your API key using "Zaius.api_key = <API-KEY>". '
231
+ end
232
+
233
+ return unless api_key =~ /\s/
234
+
235
+ raise AuthenticationError, "Your API key is invalid, as it contains whitespace."
236
+ end
237
+ end
238
+
239
+ # RequestLogContext stores information about a request that's begin made so
240
+ # that we can log certain information. It's useful because it means that we
241
+ # don't have to pass around as many parameters.
242
+ class RequestLogContext
243
+ attr_accessor :body
244
+ attr_accessor :account
245
+ attr_accessor :api_key
246
+ attr_accessor :api_version
247
+ attr_accessor :method
248
+ attr_accessor :path
249
+ attr_accessor :query_params
250
+ attr_accessor :request_id
251
+ attr_accessor :url
252
+
253
+ # The idea with this method is that we might want to update some of
254
+ # context information because a response that we've received from the API
255
+ # contains information that's more authoritative than what we started
256
+ # with for a request.
257
+ def dup_from_response(resp)
258
+ return self if resp.nil?
259
+
260
+ # Faraday's API is a little unusual. Normally it'll produce a response
261
+ # object with a `headers` method, but on error what it puts into
262
+ # `e.response` is an untyped `Hash`.
263
+ headers = if resp.is_a?(Faraday::Response)
264
+ resp.headers
265
+ else
266
+ resp[:headers]
267
+ end
268
+
269
+ context = dup
270
+ context
271
+ end
272
+ end
273
+ end