supercast 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ class DataObject
5
+ include Enumerable
6
+
7
+ @@permanent_attributes = Set.new([:id]) # rubocop:disable Style/ClassVars
8
+
9
+ # The default :id method is deprecated and isn't useful to us
10
+ undef :id if method_defined?(:id)
11
+
12
+ def initialize(id = nil, opts = {})
13
+ id, @retrieve_params = Util.normalize_id(id)
14
+ @opts = Util.normalize_opts(opts)
15
+ @original_values = {}
16
+ @values = {}
17
+ # This really belongs in Resource, but not putting it there allows us
18
+ # to have a unified inspect method
19
+ @unsaved_values = Set.new
20
+ @transient_values = Set.new
21
+ @values[:id] = id if id
22
+ end
23
+
24
+ def self.construct_from(values, opts = {})
25
+ values = Supercast::Util.symbolize_names(values)
26
+
27
+ # work around protected #initialize_from for now
28
+ new(values[:id]).send(:initialize_from, values, opts)
29
+ end
30
+
31
+ # Determines the equality of two Supercast objects. Supercast objects are
32
+ # considered to be equal if they have the same set of values and each one
33
+ # of those values is the same.
34
+ def ==(other)
35
+ other.is_a?(DataObject) &&
36
+ @values == other.instance_variable_get(:@values)
37
+ end
38
+
39
+ # Hash equality. As with `#==`, we consider two equivalent Supercast objects
40
+ # equal.
41
+ def eql?(other)
42
+ # Defer to the implementation on `#==`.
43
+ self == other
44
+ end
45
+
46
+ # As with equality in `#==` and `#eql?`, we hash two Supercast objects to the
47
+ # same value if they're equivalent objects.
48
+ def hash
49
+ @values.hash
50
+ end
51
+
52
+ def to_s(*_args)
53
+ JSON.pretty_generate(to_hash)
54
+ end
55
+
56
+ def inspect
57
+ id_string = respond_to?(:id) && !id.nil? ? " id=#{id}" : ''
58
+ "#<#{self.class}:0x#{object_id.to_s(16)}#{id_string}> JSON: " +
59
+ JSON.pretty_generate(@values)
60
+ end
61
+
62
+ # Mass assigns attributes on the model.
63
+ #
64
+ # This is a version of +update_attributes+ that takes some extra options
65
+ # for internal use.
66
+ #
67
+ # ==== Attributes
68
+ #
69
+ # * +values+ - Hash of values to use to update the current attributes of
70
+ # the object.
71
+ # * +opts+ - Options for +DataObject+ like an API key that will be reused
72
+ # on subsequent API calls.
73
+ #
74
+ # ==== Options
75
+ #
76
+ # * +:dirty+ - Whether values should be initiated as "dirty" (unsaved) and
77
+ # which applies only to new DataObjects being initiated under this
78
+ # DataObject. Defaults to true.
79
+ def update_attributes(values, opts = {}, dirty: true)
80
+ values.each do |k, v|
81
+ add_accessors([k], values) unless metaclass.method_defined?(k.to_sym)
82
+ @values[k] = Util.convert_to_supercast_object(v, opts)
83
+ dirty_value!(@values[k]) if dirty
84
+ @unsaved_values.add(k)
85
+ end
86
+ end
87
+
88
+ def [](key)
89
+ @values[key.to_sym]
90
+ end
91
+
92
+ def []=(key, value)
93
+ send(:"#{key}=", value)
94
+ end
95
+
96
+ def keys
97
+ @values.keys
98
+ end
99
+
100
+ def values
101
+ @values.values
102
+ end
103
+
104
+ def to_json(_opts)
105
+ JSON.generate(@values)
106
+ end
107
+
108
+ def as_json(*opts)
109
+ @values.as_json(*opts)
110
+ end
111
+
112
+ def to_hash
113
+ maybe_to_hash = lambda do |value|
114
+ value&.respond_to?(:to_hash) ? value.to_hash : value
115
+ end
116
+
117
+ @values.each_with_object({}) do |(key, value), acc|
118
+ acc[key] = case value
119
+ when Array
120
+ value.map(&maybe_to_hash)
121
+ else
122
+ maybe_to_hash.call(value)
123
+ end
124
+ end
125
+ end
126
+
127
+ def each(&blk)
128
+ @values.each(&blk)
129
+ end
130
+
131
+ # Sets all keys within the DataObject as unsaved so that they will be
132
+ # included with an update when #serialize_params is called.
133
+ def dirty!
134
+ @unsaved_values = Set.new(@values.keys)
135
+
136
+ @values.each_value do |v|
137
+ dirty_value!(v)
138
+ end
139
+ end
140
+
141
+ # Implements custom encoding for Ruby's Marshal. The data produced by this
142
+ # method should be comprehendable by #marshal_load.
143
+ #
144
+ # This allows us to remove certain features that cannot or should not be
145
+ # serialized.
146
+ def marshal_dump
147
+ # The Client instance in @opts is not serializable and is not
148
+ # really a property of the DataObject, so we exclude it when
149
+ # dumping
150
+ opts = @opts.clone
151
+ opts.delete(:client)
152
+ [@values, opts]
153
+ end
154
+
155
+ # Implements custom decoding for Ruby's Marshal. Consumes data that's
156
+ # produced by #marshal_dump.
157
+ def marshal_load(data)
158
+ values, opts = data
159
+ initialize(values[:id])
160
+ initialize_from(values, opts)
161
+ end
162
+
163
+ def serialize_params(options = {})
164
+ update_hash = {}
165
+
166
+ @values.each do |k, _v|
167
+ # There are a few reasons that we may want to add in a parameter for
168
+ # update:
169
+ #
170
+ # 1. The `force` option has been set.
171
+ # 2. We know that it was modified.
172
+ #
173
+ unsaved = @unsaved_values.include?(k)
174
+ next unless options[:force] || unsaved
175
+
176
+ update_hash[k.to_sym] = serialize_params_value(
177
+ @values[k], @original_values[k], unsaved, options[:force], key: k
178
+ )
179
+ end
180
+
181
+ # a `nil` that makes it out of `#serialize_params_value` signals an empty
182
+ # value that we shouldn't appear in the serialized form of the object
183
+ update_hash.reject! { |_, v| v.nil? }
184
+
185
+ update_hash
186
+ end
187
+
188
+ # A protected field is one that doesn't get an accessor assigned to it
189
+ # (i.e. `obj.public = ...`) and one which is not allowed to be updated via
190
+ # the class level `Model.update(id, { ... })`.
191
+ def self.protected_fields
192
+ []
193
+ end
194
+
195
+ protected
196
+
197
+ def metaclass
198
+ class << self; self; end
199
+ end
200
+
201
+ def remove_accessors(keys)
202
+ # not available in the #instance_eval below
203
+ protected_fields = self.class.protected_fields
204
+
205
+ metaclass.instance_eval do
206
+ keys.each do |k|
207
+ next if protected_fields.include?(k)
208
+ next if @@permanent_attributes.include?(k)
209
+
210
+ # Remove methods for the accessor's reader and writer.
211
+ [k, :"#{k}=", :"#{k}?"].each do |method_name|
212
+ next unless method_defined?(method_name)
213
+
214
+ begin
215
+ remove_method(method_name)
216
+ rescue NameError
217
+ warn("WARNING: Unable to remove method `#{method_name}`; " \
218
+ "if custom, please consider renaming to a name that doesn't " \
219
+ 'collide with an API property name.')
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ def add_accessors(keys, values)
227
+ # not available in the #instance_eval below
228
+ protected_fields = self.class.protected_fields
229
+
230
+ metaclass.instance_eval do
231
+ keys.each do |k|
232
+ next if protected_fields.include?(k)
233
+ next if @@permanent_attributes.include?(k)
234
+
235
+ if k == :method
236
+ # Object#method is a built-in Ruby method that accepts a symbol
237
+ # and returns the corresponding Method object. Because the API may
238
+ # also use `method` as a field name, we check the arity of *args
239
+ # to decide whether to act as a getter or call the parent method.
240
+ define_method(k) { |*args| args.empty? ? @values[k] : super(*args) }
241
+ else
242
+ define_method(k) { @values[k] }
243
+ end
244
+
245
+ define_method(:"#{k}=") do |v|
246
+ if v == ''
247
+ raise ArgumentError, "You cannot set #{k} to an empty string. " \
248
+ 'We interpret empty strings as nil in requests. ' \
249
+ "You may set (object).#{k} = nil to delete the property."
250
+ end
251
+ @values[k] = Util.convert_to_supercast_object(v, @opts)
252
+ dirty_value!(@values[k])
253
+ @unsaved_values.add(k)
254
+ end
255
+
256
+ define_method(:"#{k}?") { @values[k] } if [FalseClass, TrueClass].include?(values[k].class)
257
+ end
258
+ end
259
+ end
260
+
261
+ # Disabling the cop because it's confused by the fact that the methods are
262
+ # protected, but we do define `#respond_to_missing?` just below. Hopefully
263
+ # this is fixed in more recent Rubocop versions.
264
+ def method_missing(name, *args)
265
+ # TODO: only allow setting in updateable classes.
266
+ if name.to_s.end_with?('=')
267
+ attr = name.to_s[0...-1].to_sym
268
+
269
+ # Pull out the assigned value. This is only used in the case of a
270
+ # boolean value to add a question mark accessor (i.e. `foo?`) for
271
+ # convenience.
272
+ val = args.first
273
+
274
+ # the second argument is only required when adding boolean accessors
275
+ add_accessors([attr], attr => val)
276
+
277
+ begin
278
+ mth = method(name)
279
+ rescue NameError
280
+ raise NoMethodError,
281
+ "Cannot set #{attr} on this object. HINT: you can't set: " \
282
+ "#{@@permanent_attributes.to_a.join(', ')}"
283
+ end
284
+ return mth.call(args[0])
285
+ elsif @values.key?(name)
286
+ return @values[name]
287
+ end
288
+
289
+ begin
290
+ super
291
+ rescue NoMethodError => e
292
+ # If we notice the accessed name if our set of transient values we can
293
+ # give the user a slightly more helpful error message. If not, just
294
+ # raise right away.
295
+ raise unless @transient_values.include?(name)
296
+
297
+ raise NoMethodError,
298
+ e.message + ". HINT: The '#{name}' attribute was set in the " \
299
+ 'past, however. It was then wiped when refreshing the object ' \
300
+ "with the result returned by Supercast's API, probably as a " \
301
+ 'result of a save(). The attributes currently available on ' \
302
+ "this object are: #{@values.keys.join(', ')}"
303
+ end
304
+ end
305
+
306
+ def respond_to_missing?(symbol, include_private = false)
307
+ @values&.key?(symbol) || super
308
+ end
309
+
310
+ # Re-initializes the object based on a hash of values (usually one that's
311
+ # come back from an API call). Adds or removes value accessors as necessary
312
+ # and updates the state of internal data.
313
+ #
314
+ # Protected on purpose! Please do not expose.
315
+ #
316
+ # ==== Options
317
+ #
318
+ # * +:values:+ Hash used to update accessors and values.
319
+ # * +:opts:+ Options for DataObject like an API key.
320
+ def initialize_from(values, opts)
321
+ @opts = Util.normalize_opts(opts)
322
+
323
+ # the `#send` is here so that we can keep this method private
324
+ @original_values = self.class.send(:deep_copy, values)
325
+
326
+ removed = Set.new(@values.keys - values.keys)
327
+ added = Set.new(values.keys - @values.keys)
328
+
329
+ remove_accessors(removed)
330
+ add_accessors(added, values)
331
+
332
+ removed.each do |k|
333
+ @values.delete(k)
334
+ @transient_values.add(k)
335
+ @unsaved_values.delete(k)
336
+ end
337
+
338
+ update_attributes(values, opts, dirty: false)
339
+
340
+ values.each_key do |k|
341
+ @transient_values.delete(k)
342
+ @unsaved_values.delete(k)
343
+ end
344
+
345
+ self
346
+ end
347
+
348
+ def serialize_params_value(value, original, _unsaved, force)
349
+ if value.nil?
350
+ ''
351
+ elsif value.is_a?(Array)
352
+ update = value.map { |v| serialize_params_value(v, nil, true, force) }
353
+
354
+ # This prevents an array that's unchanged from being resent.
355
+ update if update != serialize_params_value(original, nil, true, force)
356
+
357
+ # Handle a Hash for now, but in the long run we should be able to
358
+ # eliminate all places where hashes are stored as values internally by
359
+ # making sure any time one is set, we convert it to a DataObject. This
360
+ # will simplify our model by making data within an object more
361
+ # consistent.
362
+ #
363
+ # For now, you can still run into a hash if someone appends one to an
364
+ # existing array being held by a DataObject. This could happen for
365
+ # example by appending a new hash onto `additional_owners` for an
366
+ # account.
367
+ elsif value.is_a?(Hash)
368
+ Util.convert_to_supercast_object(value, @opts).serialize_params
369
+ elsif value.is_a?(DataObject)
370
+ value.serialize_params(force: force)
371
+ else
372
+ value
373
+ end
374
+ end
375
+
376
+ # Produces a deep copy of the given object including support for arrays,
377
+ # hashes, and DataObjects.
378
+ private_class_method def self.deep_copy(obj)
379
+ case obj
380
+ when Array
381
+ obj.map { |e| deep_copy(e) }
382
+ when Hash
383
+ obj.each_with_object({}) do |(k, v), copy|
384
+ copy[k] = deep_copy(v)
385
+ copy
386
+ end
387
+ when DataObject
388
+ obj.class.construct_from(
389
+ deep_copy(obj.instance_variable_get(:@values)),
390
+ obj.instance_variable_get(:@opts).select do |k, _v|
391
+ Util::OPTS_COPYABLE.include?(k)
392
+ end
393
+ )
394
+ else
395
+ obj
396
+ end
397
+ end
398
+
399
+ private
400
+
401
+ def dirty_value!(value)
402
+ case value
403
+ when Array
404
+ value.map { |v| dirty_value!(v) }
405
+ when DataObject
406
+ value.dirty!
407
+ end
408
+ end
409
+ end
410
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ module DataTypes
5
+ def self.object_names_to_classes
6
+ {
7
+ # data structures
8
+ DataList::OBJECT_NAME => DataList,
9
+
10
+ # business objects
11
+ Channel::OBJECT_NAME => Channel,
12
+ Creator::OBJECT_NAME => Creator,
13
+ Episode::OBJECT_NAME => Episode,
14
+ Invite::OBJECT_NAME => Invite,
15
+ Role::OBJECT_NAME => Role,
16
+ Subscriber::OBJECT_NAME => Subscriber,
17
+ UsageAlert::OBJECT_NAME => UsageAlert
18
+ }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ # SupercastError is the base error from which all other more specific Supercast
5
+ # errors derive.
6
+ class SupercastError < StandardError
7
+ attr_reader :message
8
+
9
+ # Response contains a SupercastResponse object that has some basic information
10
+ # about the response that conveyed the error.
11
+ attr_accessor :response
12
+
13
+ attr_reader :code
14
+ attr_reader :http_body
15
+ attr_reader :http_headers
16
+ attr_reader :http_status
17
+ attr_reader :json_body # equivalent to #data
18
+
19
+ # Initializes a SupercastError.
20
+ def initialize(message = nil, http_status: nil, http_body: nil, json_body: nil, http_headers: nil, code: nil)
21
+ @message = message
22
+ @http_status = http_status
23
+ @http_body = http_body
24
+ @http_headers = http_headers || {}
25
+ @json_body = json_body
26
+ @code = code
27
+ end
28
+
29
+ def to_s
30
+ status_string = @http_status.nil? ? '' : "(Status #{@http_status}) "
31
+ "#{status_string}#{@message}"
32
+ end
33
+ end
34
+
35
+ # AuthenticationError is raised when invalid credentials are used to connect
36
+ # to Supercast's servers.
37
+ class AuthenticationError < SupercastError
38
+ end
39
+
40
+ # APIConnectionError is raised in the event that the SDK can't connect to
41
+ # Supercast's servers. That can be for a variety of different reasons such as a
42
+ # downed network
43
+ class APIConnectionError < SupercastError
44
+ end
45
+
46
+ # APIError is a generic error that may be raised in cases where none of the
47
+ # other named errors cover the problem. It could also be raised in the case
48
+ # that a new error has been introduced in the API, but this version of the
49
+ # Ruby SDK doesn't know how to handle it.
50
+ class APIError < SupercastError
51
+ end
52
+
53
+ # InvalidRequestError is raised when a request is initiated with invalid
54
+ # parameters.
55
+ class InvalidRequestError < SupercastError
56
+ def initialize(message, http_status: nil, http_body: nil, json_body: nil, http_headers: nil, code: nil)
57
+ super(message, http_status: http_status, http_body: http_body,
58
+ json_body: json_body, http_headers: http_headers,
59
+ code: code)
60
+ end
61
+ end
62
+
63
+ # PermissionError is raised in cases where access was attempted on a resource
64
+ # that wasn't allowed.
65
+ class PermissionError < SupercastError
66
+ end
67
+
68
+ # RateLimitError is raised in cases where an account is putting too much load
69
+ # on Supercast's API servers (usually by performing too many requests). Please
70
+ # back off on request rate.
71
+ class RateLimitError < SupercastError
72
+ end
73
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ module Operations
5
+ module Create
6
+ # Creates an API resource.
7
+ #
8
+ # ==== Attributes
9
+ #
10
+ # * +params+ - A hash of parameters to pass to the API
11
+ # * +opts+ - A Hash of additional options (separate from the params /
12
+ # object values) to be added to the request.
13
+ def create(params = {}, opts = {})
14
+ resp, opts = request(:post, resource_url, Hash[object_name => params], opts)
15
+ Util.convert_to_supercast_object(resp.data, opts)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ module Operations
5
+ module Destroy
6
+ module ClassMethods
7
+ # Deletes an API resource
8
+ #
9
+ # Deletes the identified resource with the passed in parameters.
10
+ #
11
+ # ==== Attributes
12
+ #
13
+ # * +id+ - ID of the resource to delete.
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 destroy(id, params = {}, opts = {})
18
+ request(:delete, "#{resource_url}/#{id}", params, opts)
19
+ true
20
+ end
21
+ end
22
+
23
+ # Deletes an API resource instance
24
+ #
25
+ # Deletes the instance resource with the passed in parameters.
26
+ #
27
+ # ==== Attributes
28
+ #
29
+ # * +params+ - A hash of parameters to pass to the API
30
+ # * +opts+ - A Hash of additional options (separate from the params /
31
+ # object values) to be added to the request.
32
+ def destroy(params = {}, opts = {})
33
+ request(:delete, resource_url, params, opts)
34
+ true
35
+ end
36
+
37
+ def self.included(base)
38
+ base.extend(ClassMethods)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ module Operations
5
+ module List
6
+ # Lists an API resource
7
+ #
8
+ # ==== Attributes
9
+ #
10
+ # * +filters+ - A hash of filters to pass to the API
11
+ # * +opts+ - A Hash of additional options (separate from the params /
12
+ # object values) to be added to the request.
13
+ def list(filters = {}, opts = {})
14
+ opts = Util.normalize_opts(opts)
15
+
16
+ resp, opts = request(:get, resource_url, filters, opts)
17
+ obj = DataList.construct_from({
18
+ data: resp.data,
19
+ page: resp.http_headers['x-page'].to_i,
20
+ per_page: resp.http_headers['x-per-page'].to_i,
21
+ total: resp.http_headers['x-total'].to_i
22
+ }, opts)
23
+
24
+ # set filters so that we can fetch the same limit, expansions, and
25
+ # predicates when accessing the next and previous pages
26
+ #
27
+ # just for general cleanliness, remove any paging options
28
+ obj.filters = filters.dup
29
+ obj.filters.delete(:ending_before)
30
+ obj.filters.delete(:starting_after)
31
+
32
+ obj
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supercast
4
+ module Operations
5
+ module Request
6
+ module ClassMethods
7
+ # Invokes an HTTP request via the Supercast Client for
8
+ # manipulating an API resource.
9
+ #
10
+ # ==== Attributes
11
+ #
12
+ # * +method+ - A symbol for the HTTP verb to use in the request
13
+ # * +url+ - A string which dictates what API endpoint URL to hit
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 request(method, url, params = {}, opts = {})
18
+ warn_on_opts_in_params(params)
19
+
20
+ opts = Util.normalize_opts(opts)
21
+ opts[:client] ||= Client.active_client
22
+
23
+ headers = opts.clone
24
+ api_key = headers.delete(:api_key)
25
+ api_base = headers.delete(:api_base)
26
+ client = headers.delete(:client)
27
+ # Assume all remaining opts must be headers
28
+
29
+ resp, opts[:api_key] = client.execute_request(
30
+ method, url,
31
+ api_base: api_base, api_key: api_key,
32
+ headers: headers, params: params
33
+ )
34
+
35
+ # Hash#select returns an array before 1.9
36
+ opts_to_persist = {}
37
+ opts.each do |k, v|
38
+ opts_to_persist[k] = v if Util::OPTS_PERSISTABLE.include?(k)
39
+ end
40
+
41
+ [resp, opts_to_persist]
42
+ end
43
+
44
+ private
45
+
46
+ def warn_on_opts_in_params(params)
47
+ Util::OPTS_USER_SPECIFIED.each do |opt|
48
+ warn("WARNING: #{opt} should be in opts instead of params.") if params.key?(opt)
49
+ end
50
+ end
51
+ end
52
+
53
+ def self.included(base)
54
+ base.extend(ClassMethods)
55
+ end
56
+
57
+ protected
58
+
59
+ def request(method, url, params = {}, opts = {})
60
+ opts = @opts.merge(Util.normalize_opts(opts))
61
+ self.class.request(method, url, params, opts)
62
+ end
63
+ end
64
+ end
65
+ end