supercast 0.0.2

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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ module Operations
5
+ module Save
6
+ module ClassMethods
7
+ # Updates an API resource
8
+ #
9
+ # Updates the identified resource with the passed in parameters.
10
+ #
11
+ # ==== Attributes
12
+ #
13
+ # * +id+ - ID of the resource to update.
14
+ # * +params+ - A hash of parameters to pass to the API
15
+ # * +opts+ - A Hash of additional options (separate from the params /
16
+ # object values) to be added to the request.
17
+ def update(id, params = {}, opts = {})
18
+ params.each_key do |k|
19
+ raise ArgumentError, "Cannot update protected field: #{k}" if protected_fields.include?(k)
20
+ end
21
+
22
+ resp, opts = request(:patch, "#{resource_url}/#{id}", Hash[object_name => params], opts)
23
+ Util.convert_to_supercast_object(resp.data, opts)
24
+ end
25
+ end
26
+
27
+ # Creates or updates an API resource.
28
+ #
29
+ # If the resource doesn't yet have an assigned ID and the resource is one
30
+ # that can be created, then the method attempts to create the resource.
31
+ # The resource is updated otherwise.
32
+ #
33
+ # ==== Attributes
34
+ #
35
+ # * +params+ - Overrides any parameters in the resource's serialized data
36
+ # and includes them in the create or update. If +:req_url:+ is included
37
+ # in the list, it overrides the update URL used for the create or
38
+ # update.
39
+ # * +opts+ - A Hash of additional options (separate from the params /
40
+ # object values) to be added to the request.
41
+ def save(params = {}, opts = {})
42
+ update_attributes(params)
43
+
44
+ # Now remove any parameters that look like object attributes.
45
+ params = params.reject { |k, _| respond_to?(k) }
46
+
47
+ values = serialize_params(self).merge(params)
48
+
49
+ # note that id gets removed here our call to #url above has already
50
+ # generated a uri for this object with an identifier baked in
51
+ values.delete(:id)
52
+
53
+ resp, opts = request(save_verb, save_url, Hash[object_name => values], opts)
54
+ initialize_from(resp.data, opts)
55
+ end
56
+
57
+ def self.included(base)
58
+ base.extend(ClassMethods)
59
+ end
60
+
61
+ private
62
+
63
+ def save_verb
64
+ # This switch essentially allows us "upsert"-like functionality. If the
65
+ # API resource doesn't have an ID set (suggesting that it's new) and
66
+ # its class responds to .create (which comes from
67
+ # Supercast::Operations::Create), then use the verb to create a new
68
+ # resource, otherwise use the verb to update a resource.
69
+ self[:id].nil? && self.class.respond_to?(:create) ? :post : :patch
70
+ end
71
+
72
+ def save_url
73
+ # This switch essentially allows us "upsert"-like functionality. If the
74
+ # API resource doesn't have an ID set (suggesting that it's new) and
75
+ # its class responds to .create (which comes from
76
+ # Supercast::Operations::Create), then use the URL to create a new
77
+ # resource. Otherwise, generate a URL based on the object's identifier
78
+ # for a normal update.
79
+ if self[:id].nil? && self.class.respond_to?(:create)
80
+ self.class.resource_url
81
+ else
82
+ resource_url
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class Resource < DataObject
5
+ include Supercast::Operations::Request
6
+
7
+ def self.class_name
8
+ name.split('::')[-1]
9
+ end
10
+
11
+ def self.resource_url
12
+ if self == Resource
13
+ raise NotImplementedError,
14
+ 'Resource is an abstract class. You should perform actions ' \
15
+ 'on its subclasses (Episode, Creator, etc.)'
16
+ end
17
+
18
+ "/#{self::OBJECT_NAME.downcase}s"
19
+ end
20
+
21
+ def self.object_name
22
+ self::OBJECT_NAME
23
+ end
24
+
25
+ # Adds a custom method to a resource class. This is used to add support for
26
+ # non-CRUD API requests. custom_method takes the following parameters:
27
+ # - name: the name of the custom method to create (as a symbol)
28
+ # - http_verb: the HTTP verb for the API request (:get, :post, or :delete)
29
+ # - http_path: the path to append to the resource's URL. If not provided,
30
+ # the name is used as the path
31
+ #
32
+ # For example, this call:
33
+ # custom_method :suspend, http_verb: post
34
+ # adds a `suspend` class method to the resource class that, when called,
35
+ # will send a POST request to `/<object_name>/suspend`.
36
+ def self.custom_method(name, http_verb:, http_path: nil)
37
+ unless %i[get patch post delete].include?(http_verb)
38
+ raise ArgumentError,
39
+ "Invalid http_verb value: #{http_verb.inspect}. Should be one " \
40
+ 'of :get, :patch, :post or :delete.'
41
+ end
42
+
43
+ http_path ||= name.to_s
44
+
45
+ define_singleton_method(name) do |id, params = {}, opts = {}|
46
+ url = "#{resource_url}/#{CGI.escape(id.to_s)}/#{CGI.escape(http_path)}"
47
+ resp, opts = request(http_verb, url, params, opts)
48
+ Util.convert_to_supercast_object(resp.data, opts)
49
+ end
50
+ end
51
+
52
+ def self.retrieve(id, opts = {})
53
+ opts = Util.normalize_opts(opts)
54
+ instance = new(id, opts)
55
+ instance.refresh
56
+ instance
57
+ end
58
+
59
+ def resource_url
60
+ unless (id = self['id'])
61
+ raise InvalidRequestError.new(
62
+ "Could not determine which URL to request: #{self.class} instance " \
63
+ "has invalid ID: #{id.inspect}",
64
+ 'id'
65
+ )
66
+ end
67
+ "#{self.class.resource_url}/#{CGI.escape(id.to_s)}"
68
+ end
69
+
70
+ def object_name
71
+ self.class::OBJECT_NAME
72
+ end
73
+
74
+ def refresh
75
+ resp, opts = request(:get, resource_url, @retrieve_params)
76
+ initialize_from(resp.data, opts)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class Channel < Singleton
5
+ include Supercast::Operations::Save
6
+
7
+ OBJECT_NAME = 'channel'
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class Creator < Resource
5
+ include Supercast::Operations::Destroy
6
+ extend Supercast::Operations::List
7
+ include Supercast::Operations::Save
8
+
9
+ OBJECT_NAME = 'creator'
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class Episode < Resource
5
+ extend Supercast::Operations::Create
6
+ include Supercast::Operations::Destroy
7
+ extend Supercast::Operations::List
8
+ include Supercast::Operations::Save
9
+
10
+ OBJECT_NAME = 'episode'
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class Invite < Resource
5
+ include Supercast::Operations::Destroy
6
+ extend Supercast::Operations::List
7
+
8
+ OBJECT_NAME = 'invite'
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class Role < Resource
5
+ extend Supercast::Operations::Create
6
+ include Supercast::Operations::Destroy
7
+ extend Supercast::Operations::List
8
+ include Supercast::Operations::Save
9
+
10
+ OBJECT_NAME = 'role'
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class Subscriber < Resource
5
+ extend Supercast::Operations::Create
6
+ include Supercast::Operations::Destroy
7
+ extend Supercast::Operations::List
8
+ include Supercast::Operations::Save
9
+
10
+ OBJECT_NAME = 'subscriber'
11
+ end
12
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class UsageAlert < Resource
5
+ extend Supercast::Operations::List
6
+
7
+ OBJECT_NAME = 'usage_alert'
8
+
9
+ custom_method :dismiss, http_verb: :patch
10
+ custom_method :ignore, http_verb: :patch
11
+ custom_method :suspend, http_verb: :patch
12
+
13
+ def dismiss(params = {}, opts = {})
14
+ resp, opts = request(:patch, resource_url + '/dismiss', params, opts)
15
+ initialize_from(resp.data, opts)
16
+ end
17
+
18
+ def ignore(params = {}, opts = {})
19
+ resp, opts = request(:patch, resource_url + '/ignore', params, opts)
20
+ initialize_from(resp.data, opts)
21
+ end
22
+
23
+ def suspend(params = {}, opts = {})
24
+ resp, opts = request(:patch, resource_url + '/suspend', params, opts)
25
+ initialize_from(resp.data, opts)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'resources/channel'
4
+ require_relative 'resources/creator'
5
+ require_relative 'resources/episode'
6
+ require_relative 'resources/invite'
7
+ require_relative 'resources/role'
8
+ require_relative 'resources/subscriber'
9
+ require_relative 'resources/usage_alert'
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ # Response encapsulates some vitals of a response that came back from
5
+ # the Supercast API.
6
+ class Response
7
+ # The data contained by the HTTP body of the response deserialized from
8
+ # JSON.
9
+ attr_accessor :data
10
+
11
+ # The raw HTTP body of the response.
12
+ attr_accessor :http_body
13
+
14
+ # A Hash of the HTTP headers of the response.
15
+ attr_accessor :http_headers
16
+
17
+ # The integer HTTP status code of the response.
18
+ attr_accessor :http_status
19
+
20
+ # Initializes a Response object from a Hash like the kind returned as
21
+ # part of a Faraday exception.
22
+ #
23
+ # This may throw JSON::ParserError if the response body is not valid JSON.
24
+ def self.from_faraday_hash(http_resp)
25
+ resp = Response.new
26
+ resp.data = JSON.parse(http_resp[:body], symbolize_names: true)
27
+ resp.http_body = http_resp[:body]
28
+ resp.http_headers = http_resp[:headers]
29
+ resp.http_status = http_resp[:status]
30
+ resp
31
+ end
32
+
33
+ # Initializes a Response object from a Faraday HTTP response object.
34
+ #
35
+ # This may throw JSON::ParserError if the response body is not valid JSON.
36
+ def self.from_faraday_response(http_resp)
37
+ # handle no content responses
38
+ body = http_resp.body.empty? ? '{}' : http_resp.body
39
+
40
+ resp = Response.new
41
+ resp.data = JSON.parse(body, symbolize_names: true)
42
+ resp.http_body = body
43
+ resp.http_headers = http_resp.headers
44
+ resp.http_status = http_resp.status
45
+ resp
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class Singleton < Resource
5
+ def self.resource_url
6
+ if self == Singleton
7
+ raise NotImplementedError,
8
+ 'Singleton is an abstract class. You should ' \
9
+ 'perform actions on its subclasses (Balance, etc.)'
10
+ end
11
+
12
+ "/#{self::OBJECT_NAME.downcase}"
13
+ end
14
+
15
+ def self.retrieve(opts = {})
16
+ instance = new(nil, Util.normalize_opts(opts))
17
+ instance.refresh
18
+ instance
19
+ end
20
+
21
+ def resource_url
22
+ self.class.resource_url
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Supercast
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 DataObject 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(obj)
25
+ case obj
26
+ when Resource
27
+ obj.id
28
+ when Hash
29
+ res = {}
30
+ obj.each { |k, v| res[k] = objects_to_ids(v) unless v.nil? }
31
+ res
32
+ when Array
33
+ obj.map { |v| objects_to_ids(v) }
34
+ else
35
+ obj
36
+ end
37
+ end
38
+
39
+ def self.object_classes
40
+ @object_classes ||= Supercast::DataTypes.object_names_to_classes
41
+ end
42
+
43
+ # Converts a hash of fields or an array of hashes into a +DataObject+ or
44
+ # array of +DataObject+s. These new objects will be created as a concrete
45
+ # type as dictated by their `object` field (e.g. an `object` value of
46
+ # `creator` would create an instance of +Creator+), but if `object` is not
47
+ # present or of an unknown type, the newly created instance will fall back
48
+ # to being a +DataObject+.
49
+ #
50
+ # ==== Attributes
51
+ #
52
+ # * +data+ - Hash of fields and values to be converted into a DataObject.
53
+ # * +opts+ - Options for +DataObject+ like an API key that will be reused
54
+ # on subsequent API calls.
55
+ def self.convert_to_supercast_object(data, opts = {})
56
+ opts = normalize_opts(opts)
57
+
58
+ case data
59
+ when Array
60
+ data.map { |i| convert_to_supercast_object(i, opts) }
61
+ when Hash
62
+ # Try converting to a known object class. If none available, fall back
63
+ # to generic DataObject
64
+ object_classes.fetch(data[:object], DataObject).construct_from(data, opts)
65
+ else
66
+ data
67
+ end
68
+ end
69
+
70
+ def self.log_error(message, data = {})
71
+ log_at_level(Supercast::LEVEL_ERROR, message, data, color: :cyan)
72
+ end
73
+
74
+ def self.log_info(message, data = {})
75
+ log_at_level(Supercast::LEVEL_INFO, message, data, color: :cyan)
76
+ end
77
+
78
+ def self.log_debug(message, data = {})
79
+ log_at_level(Supercast::LEVEL_DEBUG, message, data, color: :blue)
80
+ end
81
+
82
+ def self.symbolize_names(object)
83
+ case object
84
+ when Hash
85
+ new_hash = {}
86
+ object.each do |key, value|
87
+ key = (begin
88
+ key.to_sym
89
+ rescue StandardError
90
+ key
91
+ end) || key
92
+ new_hash[key] = symbolize_names(value)
93
+ end
94
+ new_hash
95
+ when Array
96
+ object.map { |value| symbolize_names(value) }
97
+ else
98
+ object
99
+ end
100
+ end
101
+
102
+ # Encodes a hash of parameters in a way that's suitable for use as query
103
+ # parameters in a URI or as form parameters in a request body. This mainly
104
+ # involves escaping special characters from parameter keys and values (e.g.
105
+ # `&`).
106
+ def self.encode_parameters(params)
107
+ Util.flatten_params(params)
108
+ .map { |k, v| "#{url_encode(k)}=#{url_encode(v)}" }.join('&')
109
+ end
110
+
111
+ # Encodes a string in a way that makes it suitable for use in a set of
112
+ # query parameters in a URI or in a set of form parameters in a request
113
+ # body.
114
+ def self.url_encode(key)
115
+ CGI.escape(key.to_s).
116
+ # Don't use strict form encoding by changing the square bracket control
117
+ # characters back to their literals. This is fine by the server, and
118
+ # makes these parameter strings easier to read.
119
+ gsub('%5B', '[').gsub('%5D', ']')
120
+ end
121
+
122
+ def self.flatten_params(params, parent_key = nil)
123
+ result = []
124
+
125
+ # do not sort the final output because arrays (and arrays of hashes
126
+ # especially) can be order sensitive, but do sort incoming parameters
127
+ params.each do |key, value|
128
+ calculated_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
129
+ if value.is_a?(Hash)
130
+ result += flatten_params(value, calculated_key)
131
+ elsif value.is_a?(Array)
132
+ result += flatten_params_array(value, calculated_key)
133
+ else
134
+ result << [calculated_key, value]
135
+ end
136
+ end
137
+
138
+ result
139
+ end
140
+
141
+ def self.flatten_params_array(value, calculated_key)
142
+ result = []
143
+ value.each_with_index do |elem, i|
144
+ if elem.is_a?(Hash)
145
+ result += flatten_params(elem, "#{calculated_key}[#{i}]")
146
+ elsif elem.is_a?(Array)
147
+ result += flatten_params_array(elem, calculated_key)
148
+ else
149
+ result << ["#{calculated_key}[#{i}]", elem]
150
+ end
151
+ end
152
+ result
153
+ end
154
+
155
+ def self.normalize_id(id)
156
+ if id.is_a?(Hash) # overloaded id
157
+ params_hash = id.dup
158
+ id = params_hash.delete(:id)
159
+ else
160
+ params_hash = {}
161
+ end
162
+ [id, params_hash]
163
+ end
164
+
165
+ # The secondary opts argument can either be a string or hash
166
+ # Turn this value into an api_key and a set of headers
167
+ def self.normalize_opts(opts)
168
+ case opts
169
+ when String
170
+ { api_key: opts }
171
+ when Hash
172
+ check_api_key!(opts.fetch(:api_key)) if opts.key?(:api_key)
173
+ opts.clone
174
+ else
175
+ raise TypeError, 'normalize_opts expects a string or a hash'
176
+ end
177
+ end
178
+
179
+ def self.check_string_argument!(key)
180
+ raise TypeError, 'argument must be a string' unless key.is_a?(String)
181
+
182
+ key
183
+ end
184
+
185
+ def self.check_api_key!(key)
186
+ raise TypeError, 'api_key must be a string' unless key.is_a?(String)
187
+
188
+ key
189
+ end
190
+
191
+ # Normalizes header keys so that they're all lower case and each
192
+ # hyphen-delimited section starts with a single capitalized letter. For
193
+ # example, `request-id` becomes `Request-Id`. This is useful for extracting
194
+ # certain key values when the user could have set them with a variety of
195
+ # diffent naming schemes.
196
+ def self.normalize_headers(headers)
197
+ headers.each_with_object({}) do |(k, v), new_headers|
198
+ k = k.to_s.tr('_', '-') if k.is_a?(Symbol)
199
+ k = k.split('-').reject(&:empty?).map(&:capitalize).join('-')
200
+
201
+ new_headers[k] = v
202
+ end
203
+ end
204
+
205
+ # Constant time string comparison to prevent timing attacks
206
+ # Code borrowed from ActiveSupport
207
+ def self.secure_compare(str_a, str_b)
208
+ return false unless str_a.bytesize == str_b.bytesize
209
+
210
+ l = str_a.unpack "C#{str_a.bytesize}"
211
+
212
+ res = 0
213
+ str_b.each_byte { |byte| res |= byte ^ l.shift }
214
+ res.zero?
215
+ end
216
+
217
+ #
218
+ # private
219
+ #
220
+
221
+ COLOR_CODES = {
222
+ black: 0, light_black: 60,
223
+ red: 1, light_red: 61,
224
+ green: 2, light_green: 62,
225
+ yellow: 3, light_yellow: 63,
226
+ blue: 4, light_blue: 64,
227
+ magenta: 5, light_magenta: 65,
228
+ cyan: 6, light_cyan: 66,
229
+ white: 7, light_white: 67,
230
+ default: 9
231
+ }.freeze
232
+ private_constant :COLOR_CODES
233
+
234
+ # Uses an ANSI escape code to colorize text if it's going to be sent to a
235
+ # TTY.
236
+ def self.colorize(val, color, isatty)
237
+ return val unless isatty
238
+
239
+ mode = 0 # default
240
+ foreground = 30 + COLOR_CODES.fetch(color)
241
+ background = 40 + COLOR_CODES.fetch(:default)
242
+
243
+ "\033[#{mode};#{foreground};#{background}m#{val}\033[0m"
244
+ end
245
+ private_class_method :colorize
246
+
247
+ # Turns an integer log level into a printable name.
248
+ def self.level_name(level)
249
+ case level
250
+ when LEVEL_DEBUG then 'debug'
251
+ when LEVEL_ERROR then 'error'
252
+ when LEVEL_INFO then 'info'
253
+ else level
254
+ end
255
+ end
256
+ private_class_method :level_name
257
+
258
+ def self.log_at_level(level, message, data = {}, opts = {})
259
+ log_internal(message, data, { level: level, logger: Supercast.logger, out: $stdout }.merge(opts)) if !Supercast.logger.nil? || !Supercast.log_level.nil? && Supercast.log_level <= level
260
+ end
261
+ private_class_method :log_at_level
262
+
263
+ # TODO: Make these named required arguments when we drop support for Ruby
264
+ # 2.0.
265
+ def self.log_internal(message, data = {}, color: nil, level: nil, logger: nil, out: nil)
266
+ data_str = data.reject { |_k, v| v.nil? }
267
+ .map do |(k, v)|
268
+ format('%<key>s=%<value>s',
269
+ key: colorize(k, color, logger.nil? && !out.nil? && out.isatty),
270
+ value: wrap_logfmt_value(v))
271
+ end.join(' ')
272
+
273
+ if !logger.nil?
274
+ # the library's log levels are mapped to the same values as the
275
+ # standard library's logger
276
+ logger.log(level,
277
+ format('message=%<message>s %<data_str>s',
278
+ message: wrap_logfmt_value(message),
279
+ data_str: data_str))
280
+ elsif out.isatty
281
+ out.puts format('%<level>s %<message>s %<data_str>s',
282
+ level: colorize(level_name(level)[0, 4].upcase,
283
+ color, out.isatty),
284
+ message: message,
285
+ data_str: data_str)
286
+ else
287
+ out.puts format('message=%<message>s level=%<level>s %<data_str>s',
288
+ message: wrap_logfmt_value(message),
289
+ level: level_name(level),
290
+ data_str: data_str)
291
+ end
292
+ end
293
+ private_class_method :log_internal
294
+
295
+ # Wraps a value in double quotes if it looks sufficiently complex so that
296
+ # it can be read by logfmt parsers.
297
+ def self.wrap_logfmt_value(val)
298
+ # If value is any kind of number, just allow it to be formatted directly
299
+ # to a string (this will handle integers or floats).
300
+ return val if val.is_a?(Numeric)
301
+
302
+ # Hopefully val is a string, but protect in case it's not.
303
+ val = val.to_s
304
+
305
+ if %r{[^\w\-/]}.match?(val)
306
+ # If the string contains any special characters, escape any double
307
+ # quotes it has, remove newlines, and wrap the whole thing in quotes.
308
+ format(%("%<value>s"), value: val.gsub('"', '\"').delete("\n"))
309
+ else
310
+ # Otherwise use the basic value if it looks like a standard set of
311
+ # characters (and allow a few special characters like hyphens, and
312
+ # slashes)
313
+ val
314
+ end
315
+ end
316
+ private_class_method :wrap_logfmt_value
317
+ end
318
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ VERSION = '0.0.2'
5
+ end