x-resources 1.0.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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +254 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +148 -0
  6. data/lib/x/resources/abstract_class.rb +29 -0
  7. data/lib/x/resources/actions/direct_messages.rb +99 -0
  8. data/lib/x/resources/actions/engagement.rb +95 -0
  9. data/lib/x/resources/actions/lists.rb +126 -0
  10. data/lib/x/resources/actions/posts.rb +77 -0
  11. data/lib/x/resources/actions/relationships.rb +91 -0
  12. data/lib/x/resources/actions.rb +22 -0
  13. data/lib/x/resources/api.rb +40 -0
  14. data/lib/x/resources/attributes.rb +203 -0
  15. data/lib/x/resources/batch.rb +43 -0
  16. data/lib/x/resources/batch_finders.rb +185 -0
  17. data/lib/x/resources/bookmark_folder.rb +23 -0
  18. data/lib/x/resources/community.rb +137 -0
  19. data/lib/x/resources/cursor.rb +493 -0
  20. data/lib/x/resources/direct_message.rb +325 -0
  21. data/lib/x/resources/direct_message_conversations.rb +147 -0
  22. data/lib/x/resources/errors.rb +104 -0
  23. data/lib/x/resources/finders.rb +255 -0
  24. data/lib/x/resources/identity.rb +65 -0
  25. data/lib/x/resources/includes.rb +216 -0
  26. data/lib/x/resources/list.rb +336 -0
  27. data/lib/x/resources/lookups/communities.rb +56 -0
  28. data/lib/x/resources/lookups/direct_messages.rb +86 -0
  29. data/lib/x/resources/lookups/lists.rb +44 -0
  30. data/lib/x/resources/lookups/media.rb +72 -0
  31. data/lib/x/resources/lookups/posts.rb +198 -0
  32. data/lib/x/resources/lookups/spaces.rb +87 -0
  33. data/lib/x/resources/lookups/trends.rb +38 -0
  34. data/lib/x/resources/lookups/users.rb +221 -0
  35. data/lib/x/resources/lookups.rb +25 -0
  36. data/lib/x/resources/marshalling.rb +93 -0
  37. data/lib/x/resources/matching_rule.rb +107 -0
  38. data/lib/x/resources/media.rb +278 -0
  39. data/lib/x/resources/media_ids.rb +74 -0
  40. data/lib/x/resources/memo.rb +54 -0
  41. data/lib/x/resources/page.rb +394 -0
  42. data/lib/x/resources/page_limit.rb +80 -0
  43. data/lib/x/resources/pages.rb +270 -0
  44. data/lib/x/resources/parallel.rb +82 -0
  45. data/lib/x/resources/personalized_trend.rb +124 -0
  46. data/lib/x/resources/place.rb +107 -0
  47. data/lib/x/resources/poll.rb +75 -0
  48. data/lib/x/resources/post.rb +615 -0
  49. data/lib/x/resources/post_collections.rb +67 -0
  50. data/lib/x/resources/post_counts.rb +215 -0
  51. data/lib/x/resources/post_search.rb +86 -0
  52. data/lib/x/resources/post_usage.rb +203 -0
  53. data/lib/x/resources/post_writes.rb +140 -0
  54. data/lib/x/resources/published_count.rb +31 -0
  55. data/lib/x/resources/references.rb +121 -0
  56. data/lib/x/resources/relation_writes.rb +54 -0
  57. data/lib/x/resources/relationships.rb +77 -0
  58. data/lib/x/resources/resource.rb +535 -0
  59. data/lib/x/resources/serialization.rb +58 -0
  60. data/lib/x/resources/shape.rb +167 -0
  61. data/lib/x/resources/space.rb +332 -0
  62. data/lib/x/resources/topic.rb +59 -0
  63. data/lib/x/resources/trend.rb +130 -0
  64. data/lib/x/resources/user.rb +502 -0
  65. data/lib/x/resources/user_collections.rb +213 -0
  66. data/lib/x/resources/user_finders.rb +282 -0
  67. data/lib/x/resources/utils.rb +358 -0
  68. data/lib/x/resources/value_equality.rb +38 -0
  69. data/lib/x/resources/value_marshalling.rb +89 -0
  70. data/lib/x/resources/version.rb +25 -0
  71. data/lib/x/resources.rb +22 -0
  72. data/sig/manifest.yaml +7 -0
  73. data/sig/x-resources.rbs +813 -0
  74. metadata +140 -0
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "errors"
5
+ require_relative "shape"
6
+
7
+ module X
8
+ module Resources
9
+ # One page of results from a paginated endpoint
10
+ # @api public
11
+ class ::X::Page
12
+ include Enumerable
13
+
14
+ # The number of the format of the state Marshal writes, which every release of 1.x writes
15
+ #
16
+ # A later release of 1.x adds to the state only what an earlier one ignores, parts after those it reads and keys of a
17
+ # Hash it does not read, so that the state one release of 1.x writes is read by every other, earlier or later.
18
+ #
19
+ # @api private
20
+ MARSHAL_FORMAT = 1
21
+ # The name YAML writes each part of the state under, in the order Marshal writes them
22
+ # @api private
23
+ YAML_KEYS = %w[format resources meta problems includes].freeze
24
+ # The query parameters that name the resources of a lookup of several, which a problem of a response names when
25
+ # it found none of them
26
+ # @api private
27
+ LIST_PARAMETERS = %w[ids media_keys usernames].freeze
28
+ private_constant :MARSHAL_FORMAT, :YAML_KEYS, :LIST_PARAMETERS
29
+
30
+ # The resources on this page
31
+ # @api public
32
+ # @return [Array<Resource>] the resources
33
+ # @example Get the resources on a page
34
+ # page.items
35
+ attr_reader :items
36
+
37
+ # The problems the response of this page reported
38
+ #
39
+ # They are every problem of the response, where each resource of the page reports only those about it, or about
40
+ # a resource it refers to.
41
+ #
42
+ # @api public
43
+ # @return [Array<Problem>] the problems
44
+ # @example Collect every problem a cursor's pages reported
45
+ # user.followers.each_page.flat_map(&:problems)
46
+ attr_reader :problems
47
+
48
+ # The pagination metadata returned with this page
49
+ # @api public
50
+ # @return [Hash{String => Object}] the metadata
51
+ # @example Get the metadata
52
+ # page.meta # => {"result_count" => 100, "next_token" => "..."}
53
+ attr_reader :meta
54
+
55
+ # Initialize a new page
56
+ #
57
+ # @api public
58
+ # @param items [Array<Resource>] the resources on the page
59
+ # @param meta [Hash] the pagination metadata, empty by default, as for a page whose response holds none
60
+ # @param problems [Array<Problem>] the problems the page's response reported
61
+ # @return [Page] a new page, which holds frozen copies of the arrays it is given, leaving them as they are
62
+ # @raise [ArgumentError] if the items are not an Array of resources, the metadata is not a Hash, or the problems
63
+ # are not an Array of problems
64
+ # @example Create a page
65
+ # X::Page.new([user], meta: {"result_count" => 1})
66
+ def initialize(items, meta: {}, problems: [])
67
+ @items = resources!(items).dup.freeze
68
+ @meta = Utils.deep_freeze(Hash.try_convert(meta) || raise(ArgumentError, "meta must be a Hash, not #{meta.inspect}"))
69
+ @problems = problems!(problems).dup.freeze
70
+ freeze
71
+ end
72
+
73
+ # Iterate over the resources on this page
74
+ #
75
+ # @api public
76
+ # @yield [Resource] each resource
77
+ # @return [Enumerator, Page] an enumerator without a block, otherwise self, as a cursor returns itself
78
+ # @example Iterate over a page
79
+ # page.each { |user| puts user.username }
80
+ def each(&block)
81
+ return to_enum { size } unless block
82
+
83
+ items.each(&block)
84
+ self
85
+ end
86
+
87
+ # The resources on this page, frozen, as {#items} returns them
88
+ #
89
+ # @api public
90
+ # @return [Array<Resource>] the resources
91
+ # @example Get the resources on a page as an Array
92
+ # page.to_a
93
+ def to_a = items
94
+
95
+ alias_method :entries, :to_a
96
+
97
+ # The number of resources on this page
98
+ #
99
+ # @api public
100
+ # @return [Integer] the number of resources
101
+ # @example Count the resources on a page
102
+ # page.size # => 100
103
+ def size = items.size
104
+
105
+ alias_method :length, :size
106
+
107
+ # Check whether this page holds no resources
108
+ #
109
+ # @api public
110
+ # @return [Boolean] true if the page holds none
111
+ # @example Stop at an empty page
112
+ # break if page.empty?
113
+ def empty? = items.empty?
114
+
115
+ # The resource at an index, or the resources of a range, as Array#[] reads them
116
+ #
117
+ # @api public
118
+ # @param args [Array<Integer, Range>] an index, a start and a length, or a range
119
+ # @return [Resource, Array<Resource>, nil] the resource, or the resources, or nil for an index past the end
120
+ # @example Get the first resource on a page
121
+ # page[0]
122
+ def [](*args) = items[*args] # steep:ignore DifferentMethodParameterKind, UnresolvedOverloading
123
+
124
+ # The last resource on this page, or the last few
125
+ #
126
+ # @api public
127
+ # @param args [Array<Integer>] nothing for the last resource, or the number of resources to take from the end
128
+ # @return [Resource, Array<Resource>, nil] the last resource, or the last resources, or nil for an empty page
129
+ # @example Get the last resource on a page
130
+ # page.last
131
+ def last(*args) = items.last(*args) # steep:ignore DifferentMethodParameterKind, UnresolvedOverloading
132
+
133
+ # Check whether another page is the same page
134
+ #
135
+ # Its resources are compared as resources are, by class and identifier, so a page read again, or read back from
136
+ # Marshal, equals the page it was read from.
137
+ #
138
+ # @api public
139
+ # @param other [Object] the other page
140
+ # @return [Boolean] true if the other page is a Page of the same resources, in the same order, with the same meta
141
+ # and problems
142
+ # @example Check whether a page was read before
143
+ # seen.include?(page)
144
+ def ==(other) = other.instance_of?(self.class) && state.eql?(other.__send__(:state))
145
+ alias_method :eql?, :==
146
+
147
+ # The hash of the page, which equal pages share
148
+ #
149
+ # @api public
150
+ # @return [Integer] the hash
151
+ # @example Count the distinct pages
152
+ # pages.uniq.size
153
+ def hash = [self.class, state].hash
154
+
155
+ # The token used to fetch the next page
156
+ #
157
+ # An empty token names no page, so a page whose meta holds one is the last, as a page whose meta holds none is,
158
+ # rather than one whose next page is fetched with an empty token the API refuses.
159
+ #
160
+ # @api public
161
+ # @return [String, nil] the token or nil if this is the last page
162
+ # @example Get the next token
163
+ # page.next_token
164
+ def next_token
165
+ token = meta["next_token"]
166
+ token unless token.eql?("")
167
+ end
168
+
169
+ # The token used to fetch the page before this one
170
+ #
171
+ # Most endpoints that page, such as the followers of a user, the members of a list, and the events of a direct
172
+ # message conversation, name the page before each page after the first. An empty token names no page, as an
173
+ # empty next_token does.
174
+ #
175
+ # @api public
176
+ # @return [String, nil] the token or nil if this is the first page, or the endpoint names none
177
+ # @example Fetch the page before this one
178
+ # client.get("users/7505382/followers?pagination_token=#{page.previous_token}", object_class: X::User)
179
+ def previous_token
180
+ token = meta["previous_token"]
181
+ token unless token.eql?("")
182
+ end
183
+
184
+ # The number of results reported by the API
185
+ #
186
+ # @api public
187
+ # @return [Integer, nil] the result count
188
+ # @raise [InvalidAttribute] if the meta holds a result count that is not a number
189
+ # @example Get the result count
190
+ # page.result_count
191
+ def result_count
192
+ Utils.read("#{self.class}#result_count", meta["result_count"]) { |value| Shape.integer(value) }
193
+ end
194
+
195
+ # This page, as a JSON encoder reads it, in the shape of the response it came from
196
+ #
197
+ # Its resources are the data, each given as its own as_json gives it, beside the meta of the page, which holds
198
+ # the token of the next, and, when the response reported any, its problems as the errors, so that what this
199
+ # returns is plain data, which ActiveSupport reads too, and which the from_response of the resource class builds
200
+ # into a page again. The objects the response included are not among it, so a reference of a resource built
201
+ # again from it is a stub.
202
+ #
203
+ # @api public
204
+ # @return [Hash{String => Object}] the data, meta, and errors of the page, frozen
205
+ # @example Serialize a page
206
+ # page.as_json # => {"data" => [{"id" => "7505382"}], "meta" => {"next_token" => "abc"}}
207
+ # @example Build a page again from what it serialized to
208
+ # X::User.from_response(JSON.parse(page.to_json), client: client)
209
+ def as_json(*)
210
+ json = {"data" => map(&:as_json), "meta" => meta}
211
+ json["errors"] = problems.map(&:to_h) unless problems.empty?
212
+ json.freeze
213
+ end
214
+
215
+ # This page as a Hash in the shape of its response, or of a pair for each resource
216
+ #
217
+ # Without a block it is {#as_json}, as the to_h of a resource is its attributes, rather than the to_h of
218
+ # Enumerable, which raises TypeError for resources that are not pairs. With a block it is the to_h of Enumerable,
219
+ # which builds a Hash of the pair the block returns for each resource.
220
+ #
221
+ # @api public
222
+ # @yieldparam resource [Resource] each resource
223
+ # @yieldreturn [Array(Object, Object)] the key and value of the resource
224
+ # @return [Hash] the data, meta, and errors of the page, frozen, or the pairs the block returns
225
+ # @example Get the page in the shape of its response
226
+ # page.to_h # => {"data" => [{"id" => "7505382"}], "meta" => {"next_token" => "abc"}}
227
+ # @example Index the users of a page by username
228
+ # page.to_h { |user| [user.username, user] }
229
+ def to_h(&block) = block ? super() : as_json
230
+
231
+ # This page as a JSON object in the shape of the response it came from
232
+ #
233
+ # @api public
234
+ # @param state [JSON::State, nil] the state a JSON encoder passes, which the attributes are given
235
+ # @return [String] the data, meta, and errors of the page as a JSON object
236
+ # @example Serialize a page
237
+ # page.to_json # => "{\"data\":[{\"id\":\"7505382\"}],\"meta\":{}}"
238
+ def to_json(state = nil) = as_json.to_json(state)
239
+
240
+ # The meta of a response, which holds the token of its next page
241
+ #
242
+ # A cursor and the from_response of a resource class read the meta of the pages they build with it, so that a
243
+ # meta that is not an object raises alike, rather than end the paging of one of them without a word.
244
+ #
245
+ # @api private
246
+ # @param body [Hash, nil] the parsed response body
247
+ # @return [Hash{String => Object}] the meta, empty if the response holds none
248
+ # @raise [InvalidAttribute] if the response holds a meta that is not an object
249
+ # @example Read the meta of a response
250
+ # X::Page.__send__(:meta_of, {"meta" => {"next_token" => "abc"}}) # => {"next_token" => "abc"}
251
+ def self.meta_of(body) = Shape.read_object("#{self}#meta", body.to_h["meta"]) || {}
252
+ private_class_method :meta_of
253
+
254
+ # Whether a response holds a list
255
+ #
256
+ # The from_response of a resource class builds a page of a response that holds one.
257
+ #
258
+ # A response holds one when its data is an array, and when it holds no data and either a meta, which must then
259
+ # be an object, or a problem that names a parameter of a lookup of several, as one that found none of them does.
260
+ #
261
+ # @api private
262
+ # @param body [Hash] the parsed response body
263
+ # @return [Boolean] true if the response holds a list
264
+ # @example Ask whether an empty list is one
265
+ # X::Page.__send__(:list?, {"meta" => {"result_count" => 0}}) # => true
266
+ def self.list?(body)
267
+ data = body["data"]
268
+ data.is_a?(Array) || (data.nil? && (body.key?("meta") || Problem.all_from(body).any? { |problem| LIST_PARAMETERS.include?(problem.parameter) }))
269
+ end
270
+ private_class_method :list?
271
+
272
+ # The state Marshal writes
273
+ #
274
+ # What is written is plain data, led by the number of its format, so that a page written by one release of 1.x is
275
+ # read by a later one: each resource, as its class, its attributes, and whether it is hydrated, without its client;
276
+ # the included objects the resources refer to, once for the resources that came from one response, as a resource
277
+ # writes those it refers to, so that the resources that resolved a reference to the same object still do; its
278
+ # metadata; and its problems.
279
+ #
280
+ # @api public
281
+ # @return [Array] the number of the format, then the state of the page
282
+ # @example Cache a page
283
+ # Rails.cache.write("followers", user.followers.page(0))
284
+ def marshal_dump
285
+ responses = group_by { |item| response_of(item) }
286
+ [MARSHAL_FORMAT, resource_states(responses.keys), meta, problems, responses.map { |includes, members| includes.state_of(members) }]
287
+ end
288
+
289
+ # Restore a page Marshal read, frozen as the page that was written was
290
+ #
291
+ # The resources that came from one response are built over one identity map again, so a reference they share
292
+ # resolves to the same object, as it did before the page was written. Each is hydrated if it was, and the query of
293
+ # its request asks for every field this release requests, as a resource Marshal reads is.
294
+ #
295
+ # @api public
296
+ # @param state [Array] the state Marshal wrote
297
+ # @return [void]
298
+ # @raise [UnsupportedFormat] if the state is of a format this release does not read
299
+ # @example Read a cached page
300
+ # Marshal.load(Marshal.dump(page)).next_token
301
+ def marshal_load(state)
302
+ format, resources, meta, problems, responses = state
303
+ raise UnsupportedFormat, "#{self.class} reads format #{MARSHAL_FORMAT} of Marshal, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format)
304
+
305
+ responses = responses.map { |data, about, query| Includes.new(data, problems: about, query:) }
306
+ initialize(resources.map { |klass, attrs, hydrated, response| read(klass, attrs, hydrated, responses.fetch(response)) }, meta:, problems:)
307
+ end
308
+
309
+ # Write the state Marshal writes as YAML, without the clients of the resources
310
+ #
311
+ # YAML reads no marshal_dump, and would write every instance variable of each resource, its client and the
312
+ # credentials it holds among them, so a page says how it is written: each part of the state Marshal writes, under
313
+ # its name.
314
+ #
315
+ # @api public
316
+ # @param coder [Psych::Coder] the coder YAML writes the page with
317
+ # @return [void]
318
+ # @example Write a page as YAML
319
+ # YAML.dump(user.followers.page(0))
320
+ def encode_with(coder) = YAML_KEYS.zip(marshal_dump) { |key, value| coder[key] = value }
321
+
322
+ # Restore a page YAML read, frozen, as Marshal restores one
323
+ #
324
+ # @api public
325
+ # @param coder [Psych::Coder] the coder YAML read the page with
326
+ # @return [void]
327
+ # @raise [UnsupportedFormat] if the state is of a format this release does not read
328
+ # @example Read a page written as YAML
329
+ # YAML.unsafe_load(YAML.dump(page)).next_token
330
+ def init_with(coder) = marshal_load(coder.map.values_at(*YAML_KEYS))
331
+
332
+ private
333
+
334
+ # What a page is compared by: its resources, meta, and problems
335
+ # @api private
336
+ # @return [Array(Array<Resource>, Hash, Array<Problem>)] the resources, meta, and problems
337
+ def state = [items, meta, problems]
338
+
339
+ # The identity map of the response a resource came from
340
+ # @api private
341
+ # @param resource [Resource] the resource
342
+ # @return [Resources::Includes] the identity map
343
+ def response_of(resource) = resource.__send__(:includes)
344
+
345
+ # Build a resource Marshal read of the page, over the identity map of its response
346
+ # @api private
347
+ # @param klass [Class] the resource class
348
+ # @param attrs [Hash] the attributes
349
+ # @param hydrated [Boolean] whether the resource was hydrated as it was written
350
+ # @param includes [Resources::Includes] the identity map of its response
351
+ # @return [Resource] the resource
352
+ def read(klass, attrs, hydrated, includes) = klass.__send__(:build, attrs, includes:, hydrated: includes.hydrated_as_read?(klass, hydrated))
353
+
354
+ # What Marshal writes of each resource of the page
355
+ #
356
+ # It is the class of the resource, its attributes, whether it is hydrated, and which response it came from.
357
+ #
358
+ # @api private
359
+ # @param responses [Array<Resources::Includes>] the identity map of each response the resources came from
360
+ # @return [Array<Array(Class, Hash, Boolean, Integer)>] the state of each resource
361
+ def resource_states(responses)
362
+ indexes = responses.each_with_index.to_h
363
+ map { |item| [item.class, item.attrs, item.hydrated?, indexes.fetch(response_of(item))] } #: Array[[singleton(Resource), Resources::attrs, bool, Integer]]
364
+ end
365
+
366
+ # The resources a page is given, which must be an Array of them
367
+ #
368
+ # Items that are not, such as nil, would otherwise be taken, and raise NoMethodError when the page is read.
369
+ #
370
+ # @api private
371
+ # @param items [Array<Resource>] the resources
372
+ # @return [Array<Resource>] the resources
373
+ # @raise [ArgumentError] if the items are not an Array of resources
374
+ def resources!(items)
375
+ resources = Array.try_convert(items)
376
+ return resources if resources&.all?(Resource)
377
+
378
+ raise ArgumentError, "items must be an Array of resources, not #{items.inspect}"
379
+ end
380
+
381
+ # The problems a page is given, which must be an Array of problems
382
+ # @api private
383
+ # @param problems [Object] the problems
384
+ # @return [Array<Problem>] the problems
385
+ # @raise [ArgumentError] if the problems are not an Array of problems
386
+ def problems!(problems)
387
+ array = Array.try_convert(problems)
388
+ return array if array&.all?(Problem)
389
+
390
+ raise ArgumentError, "problems must be an Array of problems, not #{problems.inspect}"
391
+ end
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module X
6
+ module Resources
7
+ # The limit of the pages a scan or a count reads, which the max_pages of each sets
8
+ #
9
+ # A scan, such as List#member?, and a count of the full archive, such as X::Post.count_all, read as many pages as
10
+ # the answer takes, and the API bills each one, so max_pages limits them, raising PageLimitReached rather than
11
+ # read past it, or answer from the pages it read.
12
+ #
13
+ # @api private
14
+ module PageLimit
15
+ extend self
16
+
17
+ # The message of the error raised for a max_pages that is neither a count of pages nor nil
18
+ INVALID_MAX_PAGES = "max_pages must be an Integer of at least 1, or nil for no limit, not %p"
19
+ # The message of the error raised for a scan or a count that read the pages its max_pages allows
20
+ PAGE_LIMIT_REACHED = "%<what>s read the %<max_pages>d pages max_pages allows, and the API names another"
21
+ private_constant :INVALID_MAX_PAGES, :PAGE_LIMIT_REACHED
22
+
23
+ # Check that a limit of pages is a count of at least one page, or nil for no limit
24
+ #
25
+ # @api private
26
+ # @param max_pages [Object] the limit
27
+ # @return [Integer, nil] the limit
28
+ # @raise [ArgumentError] if the limit is neither an Integer of at least 1 nor nil
29
+ # @example Check a limit of ten pages
30
+ # X::Resources::PageLimit.check!(10) # => 10
31
+ def check!(max_pages)
32
+ return max_pages if max_pages.nil? || (max_pages.instance_of?(Integer) && max_pages.positive?)
33
+
34
+ raise ArgumentError, format(INVALID_MAX_PAGES, max_pages)
35
+ end
36
+
37
+ # Raise for a scan or a count that read the pages its limit allows
38
+ #
39
+ # It raises only when the API names a page after them, since a scan or a count that read every page is done.
40
+ #
41
+ # @api private
42
+ # @param what [String] what read the pages, which the error names
43
+ # @param read [Integer] the number of pages read
44
+ # @param max_pages [Integer, nil] the limit, or nil for none
45
+ # @param next_token [String, nil] the token of the page after the last read, or nil for none
46
+ # @return [void]
47
+ # @raise [PageLimitReached] if the pages read reach the limit, and the API names a page after them
48
+ # @example Stop a scan at its tenth page
49
+ # X::Resources::PageLimit.reached!("List#member?", read: 10, max_pages: 10, next_token: "7140w")
50
+ def reached!(what, read:, max_pages:, next_token:)
51
+ return unless read.eql?(max_pages) && next_token
52
+
53
+ raise PageLimitReached, format(PAGE_LIMIT_REACHED, what:, max_pages:)
54
+ end
55
+
56
+ # Scan the pages of a cursor until one holds a resource
57
+ #
58
+ # It reads no more pages than a limit allows.
59
+ #
60
+ # @api private
61
+ # @param cursor [Cursor] the cursor
62
+ # @param resource [Resource] the resource to look for
63
+ # @param what [String] what scans, which the error names
64
+ # @param max_pages [Integer, nil] the most pages to read, or nil for no limit
65
+ # @return [Boolean] true if a page holds the resource
66
+ # @raise [PageLimitReached] if the pages read reach the limit without the resource, and the API names another
67
+ # @example Scan the members of a list for a user, ten pages at most
68
+ # X::Resources::PageLimit.scan(list.members.stubs, user, what: "List#member?", max_pages: 10)
69
+ def scan(cursor, resource, what:, max_pages:)
70
+ cursor.each_page.with_index(1) do |page, read|
71
+ return true if page.include?(resource)
72
+
73
+ reached!(what, read:, max_pages:, next_token: page.next_token)
74
+ end
75
+ false
76
+ end
77
+ end
78
+ private_constant :PageLimit
79
+ end
80
+ end