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,493 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "memo"
4
+ require_relative "page"
5
+ require_relative "pages"
6
+ require_relative "utils"
7
+
8
+ module X
9
+ module Resources
10
+ # A lazily paginated, cached, thread-safe collection of resources
11
+ # @api public
12
+ class ::X::Cursor
13
+ include Enumerable
14
+
15
+ # The query parameter most endpoints take the token of the next page in
16
+ # @api private
17
+ DEFAULT_TOKEN_PARAM = "pagination_token"
18
+ private_constant :DEFAULT_TOKEN_PARAM
19
+ # The message raised for a cursor serialized whole, which would read every page of its collection
20
+ # @api private
21
+ SERIALIZATION_MESSAGE = "Serializing a cursor would read every page of its collection, a billed request per " \
22
+ "page; serialize cursor.first(n), or cursor.to_a to read every page"
23
+ private_constant :SERIALIZATION_MESSAGE
24
+
25
+ # The class of the resources in this collection
26
+ # @api public
27
+ # @return [Class] the resource class
28
+ # @example Get the resource class
29
+ # user.followers.resource_class # => X::User
30
+ attr_reader :resource_class
31
+
32
+ # The client the resources hold, which also fetches the pages
33
+ #
34
+ # The pages of an endpoint that refuses OAuth 1.0a, such as the posts of a space, are fetched with the app-only
35
+ # client of a client that signs with it, while the resources hold the client itself.
36
+ #
37
+ # @api public
38
+ # @return [Object] the client
39
+ # @example Get the client
40
+ # cursor.client
41
+ attr_reader :client
42
+
43
+ # Build a cursor
44
+ #
45
+ # Internal to x-resources: a resource builds the cursors of its collections, and the searches and lookups build
46
+ # theirs, with it, and new is private, so that the settings a cursor pages with can change within 1.x, as the
47
+ # readers of the token parameter, the smallest page, and whether the pages are fetched as the app are private for
48
+ # the same reason. A cursor is made from another with refresh, prefetch, and stubs.
49
+ #
50
+ # @api private
51
+ # @param resource_class [Class] the class of the resources in the collection
52
+ # @param path [String] the endpoint path
53
+ # @param client [Object] the client the resources hold, which fetches the pages unless the endpoint takes
54
+ # app-only authentication
55
+ # @param params [Hash] query parameters merged over the resource class's default parameters
56
+ # @param prefetch [Boolean] whether to fetch the next page in a background thread while the current page is consumed
57
+ # @param token_param [String] the query parameter the token of the next page is sent in
58
+ # @param min_results [Integer] the smallest page the endpoint accepts, which first never asks below
59
+ # @param app_only [Boolean] whether the pages are fetched with the app-only client of the client, for an endpoint
60
+ # that refuses the OAuth 1.0a of a user, while the resources hold the client, so that they act as the user
61
+ # @param total [Proc, nil] a block returning the number of resources the API publishes for the collection, which
62
+ # reads it again when given fresh: true
63
+ # @param ids_only [Boolean] whether the endpoint gives the resources by their identifiers alone, and takes none of
64
+ # their fields, so the pages ask for none of the default parameters, and read stubs that hydrate together
65
+ # @return [Cursor] a new cursor
66
+ # @example Build a cursor over the followers of a user, which counts them with followers_count
67
+ # X::Cursor.__send__(:build, X::User, "users/7505382/followers", client: client, total: ->(fresh: false) { 42 })
68
+ # @example Build a cursor over an endpoint that pages with next_token
69
+ # X::Cursor.__send__(:build, X::User, "users/search", client: client, params: {query: "ruby"}, token_param: "next_token")
70
+ def self.build(resource_class, path, client:, params: {}, prefetch: false, token_param: DEFAULT_TOKEN_PARAM, min_results: 1, app_only: false, total: nil, ids_only: false)
71
+ allocate.tap { |cursor| cursor.__send__(:setup, resource_class, path, client:, params:, prefetch:, token_param:, min_results:, app_only:, total:, ids_only:) }
72
+ end
73
+ private_class_method :new, :build
74
+
75
+ # Check whether the next page is fetched in the background
76
+ #
77
+ # @api public
78
+ # @return [Boolean] true if pages are prefetched
79
+ # @example Check whether a cursor prefetches
80
+ # cursor.prefetch? # => false
81
+ def prefetch? = @prefetch
82
+
83
+ # Iterate over every resource, fetching pages as needed
84
+ #
85
+ # @api public
86
+ # @yield [Resource] each resource
87
+ # @return [Enumerator, Cursor] an enumerator without a block, otherwise self
88
+ # @example Print every follower
89
+ # user.followers.each { |follower| puts follower.username }
90
+ def each(&block)
91
+ return to_enum unless block
92
+
93
+ each_page { |page| page.each(&block) }
94
+ end
95
+
96
+ # Iterate over every page, fetching pages as needed
97
+ #
98
+ # The pages are those {#page} reads, as they were fetched, so a page need not hold as many resources as the
99
+ # largest page the endpoint allows.
100
+ #
101
+ # @api public
102
+ # @yield [Page] each page
103
+ # @return [Enumerator, Cursor] an enumerator without a block, otherwise self
104
+ # @example Print the size of every page
105
+ # user.followers.each_page { |page| puts page.result_count }
106
+ def each_page
107
+ return to_enum(:each_page) unless block_given?
108
+
109
+ index = 0
110
+ while (current = page(index))
111
+ yield current
112
+ index += 1
113
+ end
114
+ self
115
+ end
116
+
117
+ # Fetch a page by index, using the cache when possible
118
+ #
119
+ # The pages before the one asked for are read first, since the token of each asks for the next. A page is the
120
+ # page as it was fetched and kept, whatever fetched it: iterating fetches the largest page the endpoint allows,
121
+ # but first, take, any?, and empty? fetch pages no larger than they need, which the cursor keeps too, so that
122
+ # an iteration after them does not pay again for what they read. After user.followers.first, the first page
123
+ # holds one follower, and the pages an iteration fetches after it as many as the largest page does. The API may
124
+ # serve a page with fewer resources than it was asked for, or none, so no page has a size to rely on.
125
+ #
126
+ # @api public
127
+ # @param index [Integer] the zero-based page index
128
+ # @return [Page, nil] the page or nil if the collection has fewer pages
129
+ # @raise [TypeError] if the index is not an Integer, such as the String "1" or the Float 1.5, which name no page
130
+ # @raise [ArgumentError] if the index is negative, since pages are read forward from the first
131
+ # @example Fetch the first page
132
+ # user.followers.page(0)
133
+ def page(index) = @pages.at(index)
134
+
135
+ # Return a new cursor over the same collection with an empty page cache
136
+ #
137
+ # The number the API publishes for the collection is read again too, once, the first time published_count asks
138
+ # for it, since the collection it counts may have changed.
139
+ #
140
+ # @api public
141
+ # @return [Cursor] a new cursor
142
+ # @example Iterate again with fresh data
143
+ # followers = user.followers.refresh
144
+ def refresh = self.class.__send__(:build, resource_class, path, client:, params: own_params, prefetch: prefetch?, token_param:, min_results:, app_only: app_only?, total: fresh_total, ids_only: ids_only?)
145
+
146
+ # Return a new cursor over the same collection with prefetching enabled
147
+ #
148
+ # A page the background thread fails to fetch is not requested again when it is reached: the error the thread
149
+ # failed with is raised there, once, and a page asked for again after it is requested again.
150
+ #
151
+ # @api public
152
+ # @return [Cursor] a new cursor
153
+ # @example Fetch every follower while overlapping requests with processing
154
+ # user.followers.prefetch.each { |follower| process(follower) }
155
+ def prefetch = self.class.__send__(:build, resource_class, path, client:, params: own_params, prefetch: true, token_param:, min_results:, app_only: app_only?, total: @total, ids_only: ids_only?)
156
+
157
+ # Return a new cursor over the same collection that yields stubs
158
+ #
159
+ # The requests ask for nothing but identifiers, and each resource is a stub holding only its identifier,
160
+ # which hydrates on demand, even when the API returns a few default fields alongside it.
161
+ #
162
+ # @api public
163
+ # @return [Cursor] a new cursor
164
+ # @raise [UnsupportedOperation] if the resource class has no fields parameter
165
+ # @example Check whether a user is among thousands of followers without fetching their fields
166
+ # user.followers.stubs.any?(other)
167
+ def stubs = self.class.__send__(:build, resource_class, path, client:, params: id_only_params, prefetch: prefetch?, token_param:, min_results:, app_only: app_only?, total: @total, ids_only: true)
168
+
169
+ # The first resource, or the first few, requesting pages no larger than needed
170
+ #
171
+ # Iterating a cursor requests the largest page an endpoint allows, which costs the least in requests.
172
+ # The API bills each resource returned, so first asks for a page of the size it needs instead, raised to
173
+ # the endpoint's minimum, and each page after the first asks for no more than the pages before it left.
174
+ # The API may serve an empty page with the token of the next, having left out what it filters, such as
175
+ # suspended users, so first reads on until it finds a resource. The cursor keeps the pages first reads, as it
176
+ # keeps every page, so a cursor whose pages already hold what is asked for answers from them, without a request,
177
+ # and an iteration after first requests only what first left.
178
+ #
179
+ # @api public
180
+ # @param count [Integer, nil] the number of resources, or nil for the first resource alone; a Float is read as
181
+ # the Integer it converts to, as Array#first reads it
182
+ # @return [Resource, Array<Resource>, nil] the first resource, or the first resources, frozen
183
+ # @raise [ArgumentError] if the count is negative
184
+ # @raise [TypeError] if the count is not a number that converts to an Integer
185
+ # @example Read ten followers in one request for ten users
186
+ # user.followers.first(10)
187
+ def first(count = nil)
188
+ resources = @pages.read(count.nil? ? 1 : Utils.count!(count)) #: Array[untyped]
189
+ return resources unless count.nil?
190
+
191
+ resource, = resources
192
+ resource
193
+ end
194
+
195
+ # Every resource, fetching every page
196
+ #
197
+ # The array is frozen, as the arrays first and take return are, since a cursor keeps the pages it read and a
198
+ # caller that changed what it returned would change nothing the cursor holds.
199
+ #
200
+ # @api public
201
+ # @return [Array<Resource>] every resource, frozen
202
+ # @example Read every follower
203
+ # user.followers.to_a
204
+ def to_a = super.freeze
205
+
206
+ alias_method :entries, :to_a
207
+
208
+ # The first few resources, requesting pages no larger than needed, as first does
209
+ #
210
+ # @api public
211
+ # @param count [Integer] the number of resources; a Float is read as the Integer it converts to, as Array#take
212
+ # reads it
213
+ # @return [Array<Resource>] the first resources, frozen
214
+ # @raise [ArgumentError] if the count is negative
215
+ # @raise [TypeError] if the count is not a number that converts to an Integer, such as nil or a String
216
+ # @example Read three followers in one request for three users
217
+ # user.followers.take(3)
218
+ def take(count) = first(Utils.count!(count))
219
+
220
+ # Check whether the collection holds any resource, requesting one
221
+ #
222
+ # Without a pattern or a block, this asks for a single resource, raised to the endpoint's minimum, rather than a
223
+ # full page.
224
+ #
225
+ # @api public
226
+ # @param pattern [Object] a pattern each resource is matched against
227
+ # @yield [Resource] each resource
228
+ # @return [Boolean] true if any resource matches
229
+ # @example Check whether a user has any followers
230
+ # user.followers.any?
231
+ def any?(*pattern, &block)
232
+ return super unless pattern.empty? && block.nil?
233
+
234
+ !first.nil?
235
+ end
236
+
237
+ # Check whether the collection holds no resource, requesting one
238
+ #
239
+ # Without a pattern or a block, this asks for a single resource, raised to the endpoint's minimum, rather than a
240
+ # full page.
241
+ #
242
+ # @api public
243
+ # @param pattern [Object] a pattern each resource is matched against
244
+ # @yield [Resource] each resource
245
+ # @return [Boolean] true if no resource matches
246
+ # @example Check whether a user follows nobody
247
+ # user.following.none?
248
+ def none?(*pattern, &block)
249
+ return super unless pattern.empty? && block.nil?
250
+
251
+ first.nil?
252
+ end
253
+
254
+ # Check whether the collection is empty, requesting one resource
255
+ #
256
+ # Like none? without a pattern or a block, this asks for a single resource, raised to the endpoint's minimum,
257
+ # rather than a full page.
258
+ #
259
+ # @api public
260
+ # @return [Boolean] true if the collection holds no resource
261
+ # @example Check whether a user has no followers
262
+ # user.followers.empty?
263
+ def empty? = first.nil?
264
+
265
+ # Check whether the collection holds one resource, requesting two
266
+ #
267
+ # Without a pattern or a block, this asks for two resources, raised to the endpoint's minimum, rather than a full
268
+ # page.
269
+ #
270
+ # @api public
271
+ # @param pattern [Object] a pattern each resource is matched against
272
+ # @yield [Resource] each resource
273
+ # @return [Boolean] true if exactly one resource matches
274
+ # @example Check whether a list has a single member
275
+ # list.members.one?
276
+ def one?(*pattern, &block)
277
+ return super unless pattern.empty? && block.nil?
278
+
279
+ take(2).size.eql?(1)
280
+ end
281
+
282
+ # The number the API publishes for the collection, without reading any of it
283
+ #
284
+ # The API publishes a number for a user's followers, followed users, and list memberships, and for a list's
285
+ # members and followers. Reading it costs no request when the user or list holds it, and one lookup when it is a
286
+ # stub. The number counts what the collection holds, which can differ from what count reads, since the
287
+ # endpoint leaves out what the authenticated user cannot see, such as private lists and suspended users. count
288
+ # instead reads every page of the collection, a request per page, and the API bills each resource. A cursor
289
+ # answers no size, so that Ruby's own methods, such as each_slice and lazy, do not page a collection to size it.
290
+ #
291
+ # @api public
292
+ # @return [Integer, nil] the published number, or nil for a collection the API publishes no number for
293
+ # @example Count a user's followers without reading one of them
294
+ # user.followers.published_count # => 12345
295
+ def published_count = @total&.call
296
+
297
+ # The identifiers of every resource, requesting nothing but identifiers
298
+ #
299
+ # @api public
300
+ # @return [Array<Integer, String>] the identifiers, Integers unless the resource's identifiers are not numbers,
301
+ # frozen
302
+ # @raise [UnsupportedOperation] if the resource class has no fields parameter
303
+ # @example Get the identifiers of every follower
304
+ # user.followers.ids
305
+ def ids = stubs.map(&:id).freeze
306
+
307
+ # Refuse to write the collection as JSON, which would read every page of it
308
+ #
309
+ # Serializing a cursor would read every page of the collection, a request per page, and the API bills each
310
+ # resource it returns, from a call that says nothing of it, such as a cursor in a Hash that a log or a render
311
+ # writes. It raises instead, as ActiveSupport would otherwise read a cursor as the Enumerable it is. Serialize what
312
+ # first(n) or to_a reads instead, each of which says at the call how much it reads.
313
+ #
314
+ # @api public
315
+ # @return [void]
316
+ # @raise [UnsupportedOperation] always
317
+ # @example Serialize the first ten followers rather than every one of them
318
+ # user.followers.first(10).as_json
319
+ def as_json(*) = raise(UnsupportedOperation, SERIALIZATION_MESSAGE)
320
+
321
+ # A Hash of the pair a block returns for each resource, reading every page
322
+ #
323
+ # Without a block it raises as {#as_json} does, before it reads a page, since a page of the collection is read as
324
+ # a Hash only by its as_json, rather than raise TypeError from the to_h of Enumerable once it has read one.
325
+ #
326
+ # @api public
327
+ # @yieldparam resource [Resource] each resource
328
+ # @yieldreturn [Array(Object, Object)] the key and value of the resource
329
+ # @return [Hash] the pairs the block returns
330
+ # @raise [UnsupportedOperation] if no block is given
331
+ # @example Index the members of a list by username
332
+ # list.members.to_h { |user| [user.username, user] }
333
+ def to_h(&block) = block ? super() : raise(UnsupportedOperation, SERIALIZATION_MESSAGE)
334
+
335
+ # Refuse to write the collection as a JSON array, which would read every page
336
+ #
337
+ # It raises as {#as_json} does, for the reason that says.
338
+ #
339
+ # @api public
340
+ # @param _state [JSON::State, nil] the state a JSON encoder passes
341
+ # @return [void]
342
+ # @raise [UnsupportedOperation] always
343
+ # @example Serialize a whole collection, reading every page of it
344
+ # list.members.to_a.to_json # => "[{\"id\":\"7505382\"}]"
345
+ def to_json(_state = nil) = raise(UnsupportedOperation, SERIALIZATION_MESSAGE)
346
+
347
+ # Refuse to write the cursor with Marshal, as it refuses to write it as JSON
348
+ #
349
+ # A cursor holds its client, and the threads and locks that fetch its pages, none of which Marshal can write, and
350
+ # caching the collection it names would mean reading every page of it, as {#as_json} says. It raises the
351
+ # TypeError Marshal raises for what it cannot write, with the message of {#as_json}, rather than the one Marshal
352
+ # would raise from within the cursor. Marshal what first(n) or to_a reads, or a page, instead.
353
+ #
354
+ # @api public
355
+ # @return [void]
356
+ # @raise [TypeError] always
357
+ # @example Cache the first page of the followers of a user rather than the cursor
358
+ # Rails.cache.write("followers", user.followers.page(0))
359
+ def marshal_dump = raise(TypeError, SERIALIZATION_MESSAGE)
360
+
361
+ # Refuse to write the cursor as YAML, as it refuses Marshal
362
+ #
363
+ # YAML reads no marshal_dump, and would write every instance variable of the cursor, its client and the
364
+ # credentials it holds among them, so it raises as {#marshal_dump} does. Write what first(n) or to_a reads, or a
365
+ # page, instead.
366
+ #
367
+ # @api public
368
+ # @param _coder [Psych::Coder] the coder YAML would write the cursor with
369
+ # @return [void]
370
+ # @raise [TypeError] always
371
+ # @example Write the first page of the followers of a user as YAML rather than the cursor
372
+ # YAML.dump(user.followers.page(0))
373
+ def encode_with(_coder) = raise(TypeError, SERIALIZATION_MESSAGE)
374
+
375
+ # Summarize the cursor for the console
376
+ #
377
+ # @api public
378
+ # @return [String] the class name, resource class, and path
379
+ # @example Inspect a cursor
380
+ # user.followers.inspect # => #<X::Cursor resource_class=X::User path="users/7505382/followers">
381
+ def inspect = "#<#{self.class} resource_class=#{resource_class} path=#{path.inspect}>"
382
+
383
+ private
384
+
385
+ # Set the collection, requests, and pages of a new cursor, and freeze it
386
+ # @api private
387
+ # @return [void]
388
+ def setup(resource_class, path, client:, params:, prefetch:, token_param:, min_results:, app_only:, total:, ids_only:)
389
+ @resource_class = resource_class
390
+ @client = client
391
+ @path = path
392
+ @params = Utils.deep_freeze(Utils.merge_params(ids_only ? {} : resource_class.default_params, params))
393
+ @prefetch, @app_only, @ids_only = prefetch, app_only, ids_only
394
+ @token_param = token_param
395
+ @min_results = min_results
396
+ @total = total
397
+ @pages = Pages.new(self)
398
+ freeze
399
+ end
400
+
401
+ # The endpoint path
402
+ #
403
+ # Internal to x-resources: the pages of a cursor are requested at it, and the endpoint a collection is read from may
404
+ # change within 1.x, as the API moves one.
405
+ #
406
+ # @api private
407
+ # @return [String] the endpoint path
408
+ # @example Get the path
409
+ # user.followers.__send__(:path) # => "users/7505382/followers"
410
+ attr_reader :path
411
+
412
+ # The query parameters sent with every page request
413
+ #
414
+ # Internal to x-resources: the pages of a cursor are requested with them, and they hold the default fields of the
415
+ # resource class, which a minor release may add to, and the size of a page, which it may change.
416
+ #
417
+ # @api private
418
+ # @return [Hash{String => Object}] the query parameters
419
+ # @example Get the parameters
420
+ # user.followers.__send__(:params)["max_results"] # => 1000
421
+ attr_reader :params
422
+
423
+ # The smallest page the endpoint accepts
424
+ #
425
+ # Internal to x-resources: Pages never asks for a smaller page than this.
426
+ #
427
+ # @api private
428
+ # @return [Integer] the minimum page size
429
+ attr_reader :min_results
430
+
431
+ # The query parameter the token of the next page is sent in
432
+ #
433
+ # Internal to x-resources: Pages sends the token of each page after the first in it.
434
+ #
435
+ # @api private
436
+ # @return [String] the parameter name
437
+ attr_reader :token_param
438
+
439
+ # Check whether the pages are fetched with the app-only client of the client
440
+ #
441
+ # The space endpoints refuse the OAuth 1.0a of a user, so a cursor over one fetches its pages with the app-only
442
+ # client, while its resources hold the client, so that they act as the user. Internal to x-resources: Pages asks
443
+ # it which client fetches a page.
444
+ #
445
+ # @api private
446
+ # @return [Boolean] true if pages are fetched as the app
447
+ def app_only? = @app_only
448
+
449
+ # Check whether the endpoint gives the resources by their identifiers alone
450
+ #
451
+ # Such an endpoint takes none of their fields either. Internal to x-resources: the pages of such a cursor read stubs, which is what Pages asks it for this.
452
+ #
453
+ # @api private
454
+ # @return [Boolean] true if the pages ask for none of the default parameters, and read stubs
455
+ def ids_only? = @ids_only
456
+
457
+ # The block of a refreshed cursor, which reads the published number again once
458
+ # @api private
459
+ # @return [Proc, nil] the block, or nil for a collection the API publishes no number for
460
+ def fresh_total
461
+ total = @total or return
462
+ count = Memo.new
463
+ lambda do |fresh: false|
464
+ # @type var fresh: bool
465
+ fresh ? total.call(fresh: true) : count.fetch { total.call(fresh: true) }
466
+ end
467
+ end
468
+
469
+ # The parameters of this cursor, keeping dropped defaults dropped
470
+ # @api private
471
+ # @return [Hash{String => Object}] the parameters
472
+ def own_params
473
+ dropped = {} #: Hash[String, nil]
474
+ resource_class.default_params.each_key { |key| dropped[key] = nil }
475
+ dropped.merge(params)
476
+ end
477
+
478
+ # The query parameters that select nothing but the identifier
479
+ # @api private
480
+ # @return [Hash{String => Object}] the query parameters
481
+ # @raise [UnsupportedOperation] if the resource class has no fields parameter
482
+ def id_only_params
483
+ return params if ids_only?
484
+
485
+ fields_key = resource_class.__send__(:fields_key) || raise(UnsupportedOperation, "#{resource_class} has no fields parameter") #: String
486
+ id_key = resource_class.__send__(:id_key) #: String
487
+ dropped = {} #: Hash[String, nil]
488
+ resource_class.default_params.each_key { |key| dropped[key] = nil }
489
+ params.merge(dropped, fields_key => id_key)
490
+ end
491
+ end
492
+ end
493
+ end