-
Notifications
You must be signed in to change notification settings - Fork 186
/
Copy pathcollection.rb
493 lines (403 loc) · 14.8 KB
/
collection.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
require 'zendesk_api/resource'
require 'zendesk_api/resources'
require 'zendesk_api/search'
require 'zendesk_api/pagination'
module ZendeskAPI
# Represents a collection of resources. Lazily loaded, resources aren't
# actually fetched until explicitly needed (e.g. #each, {#fetch}).
class Collection
include ZendeskAPI::Sideloading
include Pagination
# Options passed in that are automatically converted from an array to a comma-separated list.
SPECIALLY_JOINED_PARAMS = [:ids, :only]
# @return [ZendeskAPI::Association] The class association
attr_reader :association
# @return [Faraday::Response] The last response
attr_reader :response
# @return [Hash] query options
attr_reader :options
# @return [ZendeskAPI::ClientError] The last response error
attr_reader :error
# Creates a new Collection instance. Does not fetch resources.
# Additional options are: verb (default: GET), path (default: resource param), page, per_page.
# @param [Client] client The {Client} to use.
# @param [String] resource The resource being collected.
# @param [Hash] options Any additional options to be passed in.
def initialize(client, resource, options = {})
@client, @resource_class, @resource = client, resource, resource.resource_path
@options = SilentMash.new(options)
set_association_from_options
join_special_params
@verb = @options.delete(:verb)
@includes = Array(@options.delete(:include))
# Used for Attachments, TicketComment
if @resource_class.is_a?(Class) && @resource_class.superclass == ZendeskAPI::Data
@resources = []
@fetchable = false
else
@fetchable = true
end
end
# Methods that take a Hash argument
methods = %w{create find update update_many destroy create_or_update}
methods += methods.map { |method| method + "!" }
methods.each do |deferrable|
# Passes arguments and the proper path to the resource class method.
# @param [Hash] options Options or attributes to pass
define_method deferrable do |*args|
unless @resource_class.respond_to?(deferrable)
raise NoMethodError.new("undefined method \"#{deferrable}\" for #{@resource_class}", deferrable, args)
end
args << {} unless args.last.is_a?(Hash)
args.last.merge!(:association => @association)
@resource_class.send(deferrable, @client, *args)
end
end
# Methods that take an Array argument
methods = %w{create_many! destroy_many!}
methods.each do |deferrable|
# Passes arguments and the proper path to the resource class method.
# @param [Array] array arguments
define_method deferrable do |*args|
unless @resource_class.respond_to?(deferrable)
raise NoMethodError.new("undefined method \"#{deferrable}\" for #{@resource_class}", deferrable, args)
end
array = args.last.is_a?(Array) ? args.pop : []
@resource_class.send(deferrable, @client, array, @association)
end
end
# Convenience method to build a new resource and
# add it to the collection. Fetches the collection as well.
# @param [Hash] opts Options or attributes to pass
def build(opts = {})
wrap_resource(opts, true).tap do |res|
self << res
end
end
# Convenience method to build a new resource and
# add it to the collection. Fetches the collection as well.
# @param [Hash] opts Options or attributes to pass
def build!(opts = {})
wrap_resource(opts, true).tap do |res|
fetch!
# << does a fetch too
self << res
end
end
# @return [Number] The total number of resources server-side (disregarding pagination).
def count
fetch
@count || -1
end
# @return [Number] The total number of resources server-side (disregarding pagination).
def count!
fetch!
@count || -1
end
# Saves all newly created resources stored in this collection.
# @return [Collection] self
def save
_save
end
# Saves all newly created resources stored in this collection.
# @return [Collection] self
def save!
_save(:save!)
end
# Adds an item (or items) to the list of side-loaded resources to request
# @option sideloads [Symbol or String] The item(s) to sideload
def include(*sideloads)
tap { @includes.concat(sideloads.map(&:to_s)) }
end
# Adds an item to this collection
# @option item [ZendeskAPI::Data] the resource to add
# @raise [ArgumentError] if the resource doesn't belong in this collection
def <<(item)
fetch
if item.is_a?(Resource)
if item.is_a?(@resource_class)
@resources << item
else
raise "this collection is for #{@resource_class}"
end
else
@resources << wrap_resource(item, true)
end
end
# The API path to this collection
def path
@association.generate_path(:with_parent => true)
end
# Executes actual GET from API and loads resources into proper class.
# @param [Boolean] reload Whether to disregard cache
def fetch!(reload = false)
if @resources && (!@fetchable || !reload)
return @resources
elsif association && association.options.parent && association.options.parent.new_record?
return (@resources = [])
end
get_resources(@query || path)
end
def fetch(*args)
fetch!(*args)
rescue Faraday::ClientError => e
@error = e
[]
end
# Alias for fetch(false)
def to_a
fetch
end
# Alias for fetch!(false)
def to_a!
fetch!
end
# Calls #each on every page with the passed in block
# @param [Block] block Passed to #each
def all!(start_page = @options["page"], &block)
_all(start_page, :bang, &block)
end
# Calls #each on every page with the passed in block
# @param [Block] block Passed to #each
def all(start_page = @options["page"], &block)
_all(start_page, &block)
end
def each_page!(*args, &block)
warn "ZendeskAPI::Collection#each_page! is deprecated, please use ZendeskAPI::Collection#all!"
all!(*args, &block)
end
def each_page(*args, &block)
warn "ZendeskAPI::Collection#each_page is deprecated, please use ZendeskAPI::Collection#all"
all(*args, &block)
end
# Replaces the current (loaded or not) resources with the passed in collection
# @option collection [Array] The collection to replace this one with
# @raise [ArgumentError] if any resources passed in don't belong in this collection
def replace(collection)
raise "this collection is for #{@resource_class}" if collection.any? { |r| !r.is_a?(@resource_class) }
@resources = collection
end
# Find the next page. Does one of three things:
# * If there is already a page number in the options hash, it increases it and invalidates the cache, returning the new page number.
# * If there is a next_page url cached, it executes a fetch on that url and returns the results.
# * Otherwise, returns an empty array.
def next
if @options["page"] && !cbp_request?
clear_cache
@options["page"] = @options["page"].to_i + 1
elsif (@query = @next_page)
# Send _only_ url param "?page[after]=token" to get the next page
@options.page&.delete("before")
fetch(true)
else
clear_cache
@resources = []
end
end
# Find the previous page. Does one of three things:
# * If there is already a page number in the options hash, it increases it and invalidates the cache, returning the new page number.
# * If there is a prev_page url cached, it executes a fetch on that url and returns the results.
# * Otherwise, returns an empty array.
def prev
if !cbp_request? && @options["page"].to_i > 1
clear_cache
@options["page"] -= 1
elsif (@query = @prev_page)
# Send _only_ url param "?page[before]=token" to get the prev page
@options.page&.delete("after")
fetch(true)
else
clear_cache
@resources = []
end
end
# Clears all cached resources and associated values.
def clear_cache
@resources = nil
@count = nil
@next_page = nil
@prev_page = nil
@query = nil
end
# @private
def to_ary
nil
end
def respond_to_missing?(name, include_all)
[].respond_to?(name, include_all)
end
# Sends methods to underlying array of resources.
def method_missing(name, *args, &block)
if resource_methods.include?(name)
collection_method(name, *args, &block)
elsif [].respond_to?(name, false)
array_method(name, *args, &block)
else
next_collection(name, *args, &block)
end
end
# @private
def to_s
if @resources
@resources.inspect
else
inspect = []
inspect << "options=#{@options.inspect}" if @options.any?
inspect << "path=#{path}"
"#{Inflection.singular(@resource)} collection [#{inspect.join(',')}]"
end
end
alias to_str to_s
def to_param
map(&:to_param)
end
def get_next_page_data(original_response_body)
link = original_response_body["links"]["next"]
result_key = @resource_class.model_key || "results"
while link
response = @client.connection.send("get", link).body
original_response_body[result_key] = original_response_body[result_key] + response[result_key]
link = response["meta"]["has_more"] ? response["links"]["next"] : nil
end
original_response_body
end
private
def get_resources(path_query_link)
if intentional_obp_request?
warn "Offset Based Pagination will be deprecated soon"
elsif supports_cbp? && first_cbp_request?
# only set cbp options if it's the first request, otherwise the options would be already in place
set_cbp_options
end
@response = get_response(path_query_link)
# Keep pre-existing behaviour for search/export
if path_query_link == "search/export"
handle_search_export_response(@response.body)
else
handle_response(@response.body)
end
end
def _all(start_page = @options["page"], bang = false, &block)
raise(ArgumentError, "must pass a block") unless block
page(start_page)
clear_cache
while (bang ? fetch! : fetch)
each do |resource|
block.call(resource, @options["page"] || 1)
end
last_page? ? break : self.next
end
page(nil)
clear_cache
end
def _save(method = :save)
return self unless @resources
result = true
@resources.map! do |item|
if item.respond_to?(method) && !item.destroyed? && item.changed?
result &&= item.send(method)
end
item
end
result
end
def join_special_params
# some params use comma-joined strings instead of query-based arrays for multiple values
@options.each do |k, v|
if SPECIALLY_JOINED_PARAMS.include?(k.to_sym) && v.is_a?(Array)
@options[k] = v.join(',')
end
end
end
def set_association_from_options
@collection_path = @options.delete(:collection_path)
association_options = { :path => @options.delete(:path) }
association_options[:path] ||= @collection_path.join("/") if @collection_path
@association = @options.delete(:association) || Association.new(association_options.merge(:class => @resource_class))
@collection_path ||= [@resource]
end
def get_response(path)
@error = nil
@client.connection.send(@verb || "get", path) do |req|
opts = @options.delete_if { |_, v| v.nil? }
req.params.merge!(:include => @includes.join(",")) if @includes.any?
if %w{put post}.include?(@verb.to_s)
req.body = opts
else
req.params.merge!(opts)
end
end
end
def handle_search_export_response(response_body)
assert_valid_response_body(response_body)
# Note this doesn't happen in #handle_response
response_body = get_next_page_data(response_body) if more_results?(response_body)
body = response_body.dup
results = body.delete(@resource_class.model_key) || body.delete("results")
assert_results(results, body)
@resources = results.map do |res|
wrap_resource(res)
end
end
# For both CBP and OBP
def handle_response(response_body)
assert_valid_response_body(response_body)
body = response_body.dup
results = body.delete(@resource_class.model_key) || body.delete("results")
assert_results(results, body)
@resources = results.map do |res|
wrap_resource(res)
end
set_page_and_count(body)
set_includes(@resources, @includes, body)
@resources
end
# Simplified Associations#wrap_resource
def wrap_resource(res, with_association = with_association?)
case res
when Array
wrap_resource(Hash[*res], with_association)
when Hash
res = res.merge(:association => @association) if with_association
@resource_class.new(@client, res)
else
res = { :id => res }
res.merge!(:association => @association) if with_association
@resource_class.new(@client, res)
end
end
# Two special cases, and all namespaced classes
def with_association?
[Tag, Setting].include?(@resource_class) ||
@resource_class.to_s.split("::").size > 2
end
## Method missing
def array_method(name, *args, &block)
to_a.public_send(name, *args, &block)
end
# If you call client.tickets.foo - and foo is not an attribute nor an association, it ends up here, as a new collection
def next_collection(name, *args, &block)
opts = args.last.is_a?(Hash) ? args.last : {}
opts.merge!(collection_path: [*@collection_path, name], page: nil)
# Why `page: nil`?
# when you do client.tickets.fetch followed by client.tickets.foos => the request to /tickets/foos will
# have the options page set to whatever the last options were for the tickets collection
self.class.new(@client, @resource_class, @options.merge(opts))
end
def collection_method(name, *args, &block)
@resource_class.send(name, @client, *args, &block)
end
def resource_methods
@resource_methods ||= @resource_class.singleton_methods(false).map(&:to_sym)
end
def assert_valid_response_body(response_body)
unless response_body.is_a?(Hash)
raise ZendeskAPI::Error::NetworkError, @response.env
end
end
def assert_results(results, body)
return if results
raise ZendeskAPI::Error::ClientError, "Expected #{@resource_class.model_key} or 'results' in response keys: #{body.keys.inspect}"
end
end
end